diff --git a/app/api/auth/oauth/gmail/label/route.ts b/app/api/auth/oauth/gmail/label/route.ts new file mode 100644 index 0000000000..803441a115 --- /dev/null +++ b/app/api/auth/oauth/gmail/label/route.ts @@ -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 }) + } +} diff --git a/app/api/auth/oauth/gmail/labels/route.ts b/app/api/auth/oauth/gmail/labels/route.ts new file mode 100644 index 0000000000..60ec2ec515 --- /dev/null +++ b/app/api/auth/oauth/gmail/labels/route.ts @@ -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 }) + } +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector-input.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector-input.tsx new file mode 100644 index 0000000000..202d5990da --- /dev/null +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector-input.tsx @@ -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('') + const [folderInfo, setFolderInfo] = useState(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 ( + + ) +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx index d4ddac6bf7..8cb60377a8 100644 --- a/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx @@ -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 + case 'folder-selector': + return default: return null } diff --git a/blocks/blocks/gmail.ts b/blocks/blocks/gmail.ts index 0b5df2cd46..e66cb53f75 100644 --- a/blocks/blocks/gmail.ts +++ b/blocks/blocks/gmail.ts @@ -35,6 +35,7 @@ export const GmailBlock: BlockConfig = { 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 = { 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 = { 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 = { 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 }, diff --git a/blocks/types.ts b/blocks/types.ts index c228b764a2..3edd1191fa 100644 --- a/blocks/types.ts +++ b/blocks/types.ts @@ -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' diff --git a/components/ui/folder-selector.tsx b/components/ui/folder-selector.tsx new file mode 100644 index 0000000000..d59a664fef --- /dev/null +++ b/components/ui/folder-selector.tsx @@ -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([]) + const [folders, setFolders] = useState([]) + const [selectedCredentialId, setSelectedCredentialId] = useState('') + const [selectedFolderId, setSelectedFolderId] = useState(value) + const [selectedFolder, setSelectedFolder] = useState(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('pending_service_id', effectiveServiceId) + saveToStorage('pending_oauth_scopes', requiredScopes) + saveToStorage('pending_oauth_return_url', window.location.href) + saveToStorage('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 + } + + return ( + <> +
+ + + + + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + + )} +
+ )} + + + + + + {isLoading ? ( +
+ + Loading labels... +
+ ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a Gmail account to continue. +

+
+ ) : ( +
+

No labels found.

+

+ Try a different search or account. +

+
+ )} +
+ + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ {cred.name} +
+ {cred.id === selectedCredentialId && } +
+ ))} +
+ )} + + {/* Folders list */} + {folders.length > 0 && ( + +
+ Labels +
+ {folders.map((folder) => ( + handleSelectFolder(folder)} + > +
+ {getFolderIcon('sm')} + {folder.name} + {folder.id === selectedFolderId && } +
+
+ ))} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ Connect Gmail account +
+
+
+ )} + + {/* Add another account option */} + {credentials.length > 0 && ( + + +
+ Connect Another Account +
+
+
+ )} +
+
+
+
+
+ + {showOAuthModal && ( + setShowOAuthModal(false)} + provider={provider} + toolName="Gmail" + requiredScopes={requiredScopes} + serviceId={getServiceId()} + /> + )} + + ) +} diff --git a/lib/auth.ts b/lib/auth.ts index 7594ff48f6..028e44a52f 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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', }, diff --git a/lib/oauth.ts b/lib/oauth.ts index cfc11efd54..ab19c020fe 100644 --- a/lib/oauth.ts +++ b/lib/oauth.ts @@ -63,6 +63,7 @@ export const OAUTH_PROVIDERS: Record = { 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 = { 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 = { 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 = { 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', diff --git a/tools/gmail/read.ts b/tools/gmail/read.ts index 748ca13348..c74d3cedeb 100644 --- a/tools/gmail/read.ts +++ b/tools/gmail/read.ts @@ -12,7 +12,10 @@ export const gmailReadTool: ToolConfig = { 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 = { }, 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 = { }), }, - 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 = { 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 '' +} diff --git a/tools/gmail/types.ts b/tools/gmail/types.ts index 2ef6b7a8eb..c936734c72 100644 --- a/tools/gmail/types.ts +++ b/tools/gmail/types.ts @@ -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 {