feat(tools): added folder-selector component, added to gmail read tool

This commit is contained in:
Waleed Latif
2025-03-12 02:35:53 -07:00
parent decc138350
commit 600edee8b6
11 changed files with 1193 additions and 41 deletions
+147
View File
@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { refreshOAuthToken } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
const logger = createLogger('GmailLabelAPI')
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated label request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const labelId = searchParams.get('labelId')
if (!credentialId || !labelId) {
logger.warn(`[${requestId}] Missing required parameters`)
return NextResponse.json(
{ error: 'Credential ID and Label ID are required' },
{ status: 400 }
)
}
// Get the credential from the database
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Log the credential info (without exposing sensitive data)
logger.info(
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
)
// Check if we need to refresh the token
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
const needsRefresh = !expiresAt || expiresAt <= now
let accessToken = credential.accessToken
if (needsRefresh && credential.refreshToken) {
logger.info(`[${requestId}] Token expired, attempting to refresh`)
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (!refreshedToken) {
logger.error(`[${requestId}] Failed to refresh token`)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
accessToken = refreshedToken
} catch (error) {
logger.error(`[${requestId}] Error refreshing token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} else if (!accessToken) {
logger.error(`[${requestId}] Missing access token for credential: ${credential.id}`)
return NextResponse.json({ error: 'Missing access token' }, { status: 401 })
}
// Fetch specific label from Gmail API
logger.info(`[${requestId}] Fetching label ${labelId} from Gmail API`)
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/labels/${labelId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
// Log the response status
logger.info(`[${requestId}] Gmail API response status: ${response.status}`)
if (!response.ok) {
const errorText = await response.text()
logger.error(`[${requestId}] Gmail API error response: ${errorText}`)
try {
const error = JSON.parse(errorText)
return NextResponse.json({ error }, { status: response.status })
} catch (e) {
return NextResponse.json({ error: errorText }, { status: response.status })
}
}
const label = await response.json()
// Transform the label to a more usable format
// Format the label name with proper capitalization
let formattedName = label.name
// Handle system labels (INBOX, SENT, etc.)
if (label.type === 'system') {
// Convert to title case (first letter uppercase, rest lowercase)
formattedName = label.name.charAt(0).toUpperCase() + label.name.slice(1).toLowerCase()
}
const formattedLabel = {
id: label.id,
name: formattedName,
type: label.type,
messagesTotal: label.messagesTotal || 0,
messagesUnread: label.messagesUnread || 0,
}
return NextResponse.json({ label: formattedLabel }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Gmail label:`, error)
return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 })
}
}
+153
View File
@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { refreshOAuthToken } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
const logger = createLogger('GmailLabelsAPI')
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated labels request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query')
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Log the credential info (without exposing sensitive data)
logger.info(
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
)
// Check if we need to refresh the token
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
const needsRefresh = !expiresAt || expiresAt <= now
let accessToken = credential.accessToken
if (needsRefresh && credential.refreshToken) {
logger.info(`[${requestId}] Token expired, attempting to refresh`)
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (!refreshedToken) {
logger.error(`[${requestId}] Failed to refresh token`)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
accessToken = refreshedToken
} catch (error) {
logger.error(`[${requestId}] Error refreshing token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} else if (!accessToken) {
logger.error(`[${requestId}] Missing access token for credential: ${credential.id}`)
return NextResponse.json({ error: 'Missing access token' }, { status: 401 })
}
// Fetch labels from Gmail API
logger.info(`[${requestId}] Fetching labels from Gmail API`)
const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
// Log the response status
logger.info(`[${requestId}] Gmail API response status: ${response.status}`)
if (!response.ok) {
const errorText = await response.text()
logger.error(`[${requestId}] Gmail API error response: ${errorText}`)
try {
const error = JSON.parse(errorText)
return NextResponse.json({ error }, { status: response.status })
} catch (e) {
return NextResponse.json({ error: errorText }, { status: response.status })
}
}
const data = await response.json()
// Log the number of labels received
logger.info(`[${requestId}] Received ${data.labels?.length || 0} labels from Gmail API`)
// Transform the labels to a more usable format
const labels = data.labels.map((label: any) => {
// Format the label name with proper capitalization
let formattedName = label.name
// Handle system labels (INBOX, SENT, etc.)
if (label.type === 'system') {
// Convert to title case (first letter uppercase, rest lowercase)
formattedName = label.name.charAt(0).toUpperCase() + label.name.slice(1).toLowerCase()
}
return {
id: label.id,
name: formattedName,
type: label.type,
messagesTotal: label.messagesTotal || 0,
messagesUnread: label.messagesUnread || 0,
}
})
// Filter labels if a query is provided
const filteredLabels = query
? labels.filter((label: any) =>
label.name.toLowerCase().includes((query as string).toLowerCase())
)
: labels
return NextResponse.json({ labels: filteredLabels }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Gmail labels:`, error)
return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 })
}
}
@@ -0,0 +1,54 @@
'use client'
import { useEffect, useState } from 'react'
import { FolderInfo, FolderSelector } from '@/components/ui/folder-selector'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { SubBlockConfig } from '@/blocks/types'
interface FolderSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
}
export function FolderSelectorInput({
blockId,
subBlock,
disabled = false,
}: FolderSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
// Get the current value from the store
useEffect(() => {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedFolderId(value)
} else {
const defaultValue = 'INBOX'
setSelectedFolderId(defaultValue)
setValue(blockId, subBlock.id, defaultValue)
}
}, [blockId, subBlock.id, getValue, setValue])
// Handle folder selection
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
setSelectedFolderId(folderId)
setFolderInfo(info || null)
setValue(blockId, subBlock.id, folderId)
}
return (
<FolderSelector
value={selectedFolderId}
onChange={handleFolderChange}
provider={subBlock.provider || 'google-email'}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select folder'}
disabled={disabled}
serviceId={subBlock.serviceId}
onFolderInfoChange={setFolderInfo}
/>
)
}
@@ -11,6 +11,7 @@ import { DateInput } from './components/date-input'
import { Dropdown } from './components/dropdown'
import { EvalInput } from './components/eval-input'
import { FileSelectorInput } from './components/file-selector-input'
import { FolderSelectorInput } from './components/folder-selector-input'
import { LongInput } from './components/long-input'
import { ShortInput } from './components/short-input'
import { SliderInput } from './components/slider-input'
@@ -150,6 +151,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
)
case 'file-selector':
return <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'folder-selector':
return <FolderSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
default:
return null
}
+45 -3
View File
@@ -35,6 +35,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
requiredScopes: [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
placeholder: 'Select Gmail account',
},
@@ -63,14 +64,47 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
placeholder: 'Email content',
condition: { field: 'operation', value: 'send_gmail' },
},
// Read Email Fields
// Read Email Fields - Add folder selector
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
layout: 'full',
provider: 'google-email',
serviceId: 'gmail',
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
placeholder: 'Select Gmail label/folder',
condition: { field: 'operation', value: 'read_gmail' },
},
{
id: 'unreadOnly',
title: 'Unread Only',
type: 'switch',
layout: 'full',
condition: { field: 'operation', value: 'read_gmail' },
},
{
id: 'maxResults',
title: 'Number of Emails',
type: 'short-input',
layout: 'full',
placeholder: 'Number of emails to retrieve (default: 1, max: 10)',
condition: { field: 'operation', value: 'read_gmail' },
},
{
id: 'messageId',
title: 'Message ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter message ID to read',
condition: { field: 'operation', value: 'read_gmail' },
placeholder: 'Enter message ID to read (optional)',
condition: {
field: 'operation',
value: 'read_gmail',
and: {
field: 'folder',
value: '',
},
},
},
// Search Fields
{
@@ -108,6 +142,12 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
params: (params) => {
// Pass the credential directly from the credential field
const { credential, ...rest } = params
// Set default folder to INBOX if not specified
if (rest.operation === 'read_gmail' && !rest.folder) {
rest.folder = 'INBOX'
}
return {
...rest,
credential, // Keep the credential parameter
@@ -123,7 +163,9 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
subject: { type: 'string', required: false },
body: { type: 'string', required: false },
// Read operation inputs
folder: { type: 'string', required: false },
messageId: { type: 'string', required: false },
unreadOnly: { type: 'boolean', required: false },
// Search operation inputs
query: { type: 'string', required: false },
maxResults: { type: 'number', required: false },
+1
View File
@@ -28,6 +28,7 @@ export type SubBlockType =
| 'oauth-input' // OAuth credential selector
| 'webhook-config' // Webhook configuration
| 'file-selector' // File selector for Google Drive, etc.
| 'folder-selector' // Folder selector for Gmail, etc.
// Component width setting
export type SubBlockLayout = 'full' | 'half'
+442
View File
@@ -0,0 +1,442 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, RefreshCw, Search, X } from 'lucide-react'
import { GmailIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console-logger'
import {
Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
parseProvider,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
const logger = createLogger('FolderSelector')
export interface FolderInfo {
id: string
name: string
type: string
messagesTotal?: number
messagesUnread?: number
}
interface FolderSelectorProps {
value: string
onChange: (value: string, folderInfo?: FolderInfo) => void
provider: string
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
}
export function FolderSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select folder',
disabled = false,
serviceId,
onFolderInfoChange,
}: FolderSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [folders, setFolders] = useState<FolderInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFolderId, setSelectedFolderId] = useState(value)
const [selectedFolder, setSelectedFolder] = useState<FolderInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFolder, setIsLoadingSelectedFolder] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Auto-select logic for credentials
if (data.credentials.length > 0) {
// If we already have a selected credential ID, check if it's valid
if (
selectedCredentialId &&
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
) {
// Keep the current selection
} else {
// Otherwise, select the default or first credential
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
setSelectedCredentialId(defaultCred.id)
} else if (data.credentials.length === 1) {
setSelectedCredentialId(data.credentials[0].id)
}
}
}
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch a single folder by ID when we have a selectedFolderId but no metadata
const fetchFolderById = useCallback(
async (folderId: string) => {
if (!selectedCredentialId || !folderId) return null
setIsLoadingSelectedFolder(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
labelId: folderId,
})
const response = await fetch(`/api/auth/oauth/gmail/label?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.label) {
setSelectedFolder(data.label)
onFolderInfoChange?.(data.label)
return data.label
}
} else {
logger.error('Error fetching folder by ID:', { error: await response.text() })
}
return null
} catch (error) {
logger.error('Error fetching folder by ID:', { error })
return null
} finally {
setIsLoadingSelectedFolder(false)
}
},
[selectedCredentialId, onFolderInfoChange]
)
// Fetch folders from Gmail
const fetchFolders = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId) return
setIsLoading(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
})
if (searchQuery) {
queryParams.append('query', searchQuery)
}
const response = await fetch(`/api/auth/oauth/gmail/labels?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
setFolders(data.labels || [])
// If we have a selected folder ID, find the folder info
if (selectedFolderId) {
const folderInfo = data.labels.find(
(folder: FolderInfo) => folder.id === selectedFolderId
)
if (folderInfo) {
setSelectedFolder(folderInfo)
onFolderInfoChange?.(folderInfo)
} else if (!searchQuery) {
// Only try to fetch by ID if this is not a search query
// and we couldn't find the folder in the list
fetchFolderById(selectedFolderId)
}
}
} else {
logger.error('Error fetching folders:', { error: await response.text() })
setFolders([])
}
} catch (error) {
logger.error('Error fetching folders:', { error })
setFolders([])
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectedFolderId, onFolderInfoChange, fetchFolderById]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Fetch folders when credential is selected
useEffect(() => {
if (selectedCredentialId) {
fetchFolders()
}
}, [selectedCredentialId, fetchFolders])
// Update selected folder when value changes externally
useEffect(() => {
if (value !== selectedFolderId) {
setSelectedFolderId(value)
// Find folder info if we have folders loaded
if (folders.length > 0) {
const folderInfo = folders.find((folder) => folder.id === value) || null
setSelectedFolder(folderInfo)
onFolderInfoChange?.(folderInfo)
} else if (value && selectedCredentialId) {
// If we have a value but no folders loaded yet, try to fetch the folder by ID
fetchFolderById(value)
}
}
}, [value, folders, onFolderInfoChange, selectedCredentialId, fetchFolderById])
// Try to fetch the folder by ID when credentials become available
useEffect(() => {
if (selectedCredentialId && selectedFolderId && !selectedFolder) {
fetchFolderById(selectedFolderId)
}
}, [selectedCredentialId, selectedFolderId, selectedFolder, fetchFolderById])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {
setSelectedFolderId(folder.id)
setSelectedFolder(folder)
onChange(folder.id, folder)
onFolderInfoChange?.(folder)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
const handleSearch = (value: string) => {
if (value.length > 2) {
fetchFolders(value)
} else if (value.length === 0) {
fetchFolders()
}
}
const getFolderIcon = (size: 'sm' | 'md' = 'sm') => {
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
return <GmailIcon className={iconSize} />
}
return (
<>
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedFolder ? (
<div className="flex items-center gap-2 overflow-hidden">
{getFolderIcon('sm')}
<span className="font-normal truncate">{selectedFolder.name}</span>
</div>
) : selectedFolderId && (isLoadingSelectedFolder || !selectedCredentialId) ? (
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground">Loading label...</span>
</div>
) : (
<div className="flex items-center gap-2">
{getFolderIcon('sm')}
<span className="text-muted-foreground">{label}</span>
</div>
)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]" align="start">
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className="px-3 py-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder="Search labels..." onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className="flex items-center justify-center p-4">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="ml-2">Loading labels...</span>
</div>
) : credentials.length === 0 ? (
<div className="p-4 text-center">
<p className="text-sm font-medium">No accounts connected.</p>
<p className="text-xs text-muted-foreground">
Connect a Gmail account to continue.
</p>
</div>
) : (
<div className="p-4 text-center">
<p className="text-sm font-medium">No labels found.</p>
<p className="text-xs text-muted-foreground">
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className="flex items-center gap-2">
<span className="font-normal">{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Folders list */}
{folders.length > 0 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Labels
</div>
{folders.map((folder) => (
<CommandItem
key={folder.id}
value={`folder-${folder.id}-${folder.name}`}
onSelect={() => handleSelectFolder(folder)}
>
<div className="flex items-center gap-2 overflow-hidden w-full">
{getFolderIcon('sm')}
<span className="font-normal truncate">{folder.name}</span>
{folder.id === selectedFolderId && <Check className="ml-auto h-4 w-4" />}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
<span>Connect Gmail account</span>
</div>
</CommandItem>
</CommandGroup>
)}
{/* Add another account option */}
{credentials.length > 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
<span>Connect Another Account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName="Gmail"
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}
+5
View File
@@ -144,6 +144,8 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
prompt: 'consent',
},
@@ -170,6 +172,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
},
@@ -183,6 +186,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
},
@@ -196,6 +200,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
},
+13 -3
View File
@@ -63,6 +63,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
scopes: [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
},
'google-drive': {
@@ -72,7 +73,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-drive',
icon: (props) => GoogleDriveIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/drive'],
scopes: [
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/drive.file',
],
},
'google-docs': {
id: 'google-docs',
@@ -81,7 +85,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-docs',
icon: (props) => GoogleDocsIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/documents'],
scopes: [
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/drive.file',
],
},
'google-sheets': {
id: 'google-sheets',
@@ -90,7 +97,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-sheets',
icon: (props) => GoogleSheetsIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
scopes: [
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.file',
],
},
'google-calendar': {
id: 'google-calendar',
+326 -35
View File
@@ -12,7 +12,10 @@ export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
oauth: {
required: true,
provider: 'google-email',
additionalScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
additionalScopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
},
params: {
@@ -23,13 +26,68 @@ export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
},
messageId: {
type: 'string',
required: true,
required: false,
description: 'ID of the message to read',
},
folder: {
type: 'string',
required: false,
description: 'Folder/label to read emails from',
},
unreadOnly: {
type: 'boolean',
required: false,
description: 'Only retrieve unread messages',
},
maxResults: {
type: 'number',
required: false,
description: 'Maximum number of messages to retrieve (default: 1, max: 10)',
},
},
request: {
url: (params: GmailReadParams) => `${GMAIL_API_BASE}/messages/${params.messageId}`,
url: (params) => {
// If a specific message ID is provided, fetch that message directly with full format
if (params.messageId) {
return `${GMAIL_API_BASE}/messages/${params.messageId}?format=full`
}
// Otherwise, list messages from the specified folder or INBOX by default
const url = new URL(`${GMAIL_API_BASE}/messages`)
// Build query parameters for the folder/label
const queryParams = []
// Add unread filter if specified
if (params.unreadOnly) {
queryParams.push('is:unread')
}
if (params.folder) {
// If it's a system label like INBOX, SENT, etc., use it directly
if (['INBOX', 'SENT', 'DRAFT', 'TRASH', 'SPAM'].includes(params.folder)) {
queryParams.push(`in:${params.folder.toLowerCase()}`)
} else {
// Otherwise, it's a user-defined label
queryParams.push(`label:${params.folder}`)
}
} else {
// Default to INBOX if no folder is specified
queryParams.push('in:inbox')
}
// Only add query if we have parameters
if (queryParams.length > 0) {
url.searchParams.append('q', queryParams.join(' '))
}
// Set max results (default to 1 for simplicity, max 10)
const maxResults = params.maxResults ? Math.min(params.maxResults, 10) : 1
url.searchParams.append('maxResults', maxResults.toString())
return url.toString()
},
method: 'GET',
headers: (params: GmailReadParams) => ({
Authorization: `Bearer ${params.accessToken}`,
@@ -37,42 +95,146 @@ export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
}),
},
transformResponse: async (response) => {
const data = await response.json()
transformResponse: async (response: Response, params?: GmailReadParams) => {
try {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to read email')
}
const message = data as GmailMessage
const headers = message.payload.headers
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value
const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value
let body = ''
if (message.payload.body?.data) {
body = Buffer.from(message.payload.body.data, 'base64').toString()
} else if (message.payload.parts) {
const textPart = message.payload.parts.find((p) => p.mimeType === 'text/plain')
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString()
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to read email')
}
}
return {
success: true,
output: {
content: body,
metadata: {
id: message.id,
threadId: message.threadId,
labelIds: message.labelIds,
from,
to,
subject,
// If we're fetching a single message directly (by ID)
if (params?.messageId) {
return processMessage(data)
}
// If we're listing messages, we need to fetch each message's details
if (data.messages && Array.isArray(data.messages)) {
// Return a message if no emails found
if (data.messages.length === 0) {
return {
success: true,
output: {
content: 'No messages found in the selected folder.',
metadata: {
results: [], // Use SearchMetadata format
},
},
}
}
// For agentic workflows, we'll fetch the first message by default
// If maxResults > 1, we'll return a summary of messages found
const maxResults = params?.maxResults ? Math.min(params.maxResults, 10) : 1
if (maxResults === 1) {
try {
// Get the first message details
const messageId = data.messages[0].id
const messageResponse = await fetch(
`${GMAIL_API_BASE}/messages/${messageId}?format=full`,
{
headers: {
Authorization: `Bearer ${params?.accessToken || ''}`,
'Content-Type': 'application/json',
},
}
)
if (!messageResponse.ok) {
const errorData = await messageResponse.json()
throw new Error(errorData.error?.message || 'Failed to fetch message details')
}
const message = await messageResponse.json()
return processMessage(message)
} catch (error: any) {
console.error('Error fetching message details:', error)
return {
success: true,
output: {
content: `Found messages but couldn't retrieve details: ${error.message || 'Unknown error'}`,
metadata: {
results: data.messages.map((msg: any) => ({
id: msg.id,
threadId: msg.threadId,
})),
},
},
}
}
} else {
// If maxResults > 1, fetch details for all messages
try {
const messagePromises = data.messages.slice(0, maxResults).map(async (msg: any) => {
const messageResponse = await fetch(
`${GMAIL_API_BASE}/messages/${msg.id}?format=full`,
{
headers: {
Authorization: `Bearer ${params?.accessToken || ''}`,
'Content-Type': 'application/json',
},
}
)
if (!messageResponse.ok) {
throw new Error(`Failed to fetch details for message ${msg.id}`)
}
return await messageResponse.json()
})
const messages = await Promise.all(messagePromises)
// Process all messages and create a summary
const processedMessages = messages.map(processMessageForSummary)
return {
success: true,
output: {
content: createMessagesSummary(processedMessages),
metadata: {
results: processedMessages.map((msg) => ({
id: msg.id,
threadId: msg.threadId,
subject: msg.subject,
from: msg.from,
date: msg.date,
})),
},
},
}
} catch (error: any) {
console.error('Error fetching multiple message details:', error)
return {
success: true,
output: {
content: `Found ${data.messages.length} messages but couldn't retrieve all details: ${error.message || 'Unknown error'}`,
metadata: {
results: data.messages.map((msg: any) => ({
id: msg.id,
threadId: msg.threadId,
})),
},
},
}
}
}
}
// Fallback for unexpected response format
return {
success: true,
output: {
content: 'Unexpected response format from Gmail API',
metadata: {
results: [],
},
},
},
}
} catch (error) {
console.error('Error in transformResponse:', error)
throw error
}
},
@@ -90,3 +252,132 @@ export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
return error.message || 'An unexpected error occurred while reading email'
},
}
// Helper function to process a Gmail message
function processMessage(message: GmailMessage): GmailToolResponse {
// Check if message and payload exist
if (!message || !message.payload) {
return {
success: true,
output: {
content: 'Unable to process email: Invalid message format',
metadata: {
id: message?.id || '',
threadId: message?.threadId || '',
labelIds: message?.labelIds || [],
},
},
}
}
const headers = message.payload.headers || []
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || ''
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || ''
const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || ''
const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || ''
// Extract the message body
let body = extractMessageBody(message.payload)
return {
success: true,
output: {
content: body || 'No content found in email',
metadata: {
id: message.id || '',
threadId: message.threadId || '',
labelIds: message.labelIds || [],
from,
to,
subject,
date,
},
},
}
}
// Helper function to process a message for summary (without full content)
function processMessageForSummary(message: GmailMessage): any {
if (!message || !message.payload) {
return {
id: message?.id || '',
threadId: message?.threadId || '',
subject: 'Unknown Subject',
from: 'Unknown Sender',
date: '',
snippet: message?.snippet || '',
}
}
const headers = message.payload.headers || []
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || 'No Subject'
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || 'Unknown Sender'
const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || ''
return {
id: message.id,
threadId: message.threadId,
subject,
from,
date,
snippet: message.snippet || '',
}
}
// Helper function to create a summary of multiple messages
function createMessagesSummary(messages: any[]): string {
if (messages.length === 0) {
return 'No messages found.'
}
let summary = `Found ${messages.length} messages:\n\n`
messages.forEach((msg, index) => {
summary += `${index + 1}. Subject: ${msg.subject}\n`
summary += ` From: ${msg.from}\n`
summary += ` Date: ${msg.date}\n`
summary += ` Preview: ${msg.snippet}\n\n`
})
summary += `To read a specific message, use the messageId parameter with one of these IDs: ${messages.map((m) => m.id).join(', ')}`
return summary
}
// Helper function to recursively extract message body from MIME parts
function extractMessageBody(payload: any): string {
// If the payload has a body with data, decode it
if (payload.body?.data) {
return Buffer.from(payload.body.data, 'base64').toString()
}
// If there are no parts, return empty string
if (!payload.parts || !Array.isArray(payload.parts) || payload.parts.length === 0) {
return ''
}
// First try to find a text/plain part
const textPart = payload.parts.find((part: any) => part.mimeType === 'text/plain')
if (textPart?.body?.data) {
return Buffer.from(textPart.body.data, 'base64').toString()
}
// If no text/plain, try to find text/html
const htmlPart = payload.parts.find((part: any) => part.mimeType === 'text/html')
if (htmlPart?.body?.data) {
return Buffer.from(htmlPart.body.data, 'base64').toString()
}
// If we have multipart/alternative or other complex types, recursively check parts
for (const part of payload.parts) {
if (part.parts) {
const nestedBody = extractMessageBody(part)
if (nestedBody) {
return nestedBody
}
}
}
// If we couldn't find any text content, return empty string
return ''
}
+4
View File
@@ -15,6 +15,9 @@ export interface GmailSendParams extends BaseGmailParams {
// Read operation parameters
export interface GmailReadParams extends BaseGmailParams {
messageId: string
folder: string
unreadOnly?: boolean
maxResults?: number
}
// Search operation parameters
@@ -37,6 +40,7 @@ interface EmailMetadata extends BaseGmailMetadata {
from?: string
to?: string
subject?: string
date?: string
}
interface SearchMetadata extends BaseGmailMetadata {