mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(tools): added folder-selector component, added to gmail read tool
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+54
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 ''
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user