Added gmail tool to send/reach/search gmail, added condition display of sub-blocks. Need to integrate oauth2 credentials for gmail

This commit is contained in:
Waleed Latif
2025-02-10 10:54:52 -08:00
parent 980ca6d116
commit 4b92eb0209
11 changed files with 516 additions and 7 deletions
@@ -9,7 +9,7 @@ import {
import { useSubBlockValue } from '../hooks/use-sub-block-value'
interface DropdownProps {
options: string[]
options: Array<string | { label: string; id: string }>
defaultValue?: string
blockId: string
subBlockId: string
@@ -21,14 +21,24 @@ export function Dropdown({ options, defaultValue, blockId, subBlockId }: Dropdow
// Set the value to the first option if it's not set
useEffect(() => {
if (!value && options.length > 0) {
setValue(defaultValue ?? options[0])
const firstOption = options[0]
const firstValue = typeof firstOption === 'string' ? firstOption : firstOption.id
setValue(firstValue)
}
}, [value, options, defaultValue, setValue])
const getOptionValue = (option: string | { label: string; id: string }) => {
return typeof option === 'string' ? option : option.id
}
const getOptionLabel = (option: string | { label: string; id: string }) => {
return typeof option === 'string' ? option : option.label
}
return (
<Select
value={value as string | undefined}
defaultValue={defaultValue ?? options[0]}
defaultValue={defaultValue ?? getOptionValue(options[0])}
onValueChange={(value) => setValue(value)}
>
<SelectTrigger className="text-left">
@@ -36,8 +46,8 @@ export function Dropdown({ options, defaultValue, blockId, subBlockId }: Dropdow
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
<SelectItem key={getOptionValue(option)} value={getOptionValue(option)}>
{getOptionLabel(option)}
</SelectItem>
))}
</SelectContent>
@@ -10,6 +10,7 @@ import { SliderInput } from './components/slider-input'
import { Switch } from './components/switch'
import { Table } from './components/table'
import { ToolInput } from './components/tool-input'
import { useSubBlockValue } from './hooks/use-sub-block-value'
interface SubBlockProps {
blockId: string
@@ -18,10 +19,18 @@ interface SubBlockProps {
}
export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
const [fieldValue] = useSubBlockValue(blockId, config.condition?.field || '')
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
// Check if the sub-block should be rendered based on its condition
const shouldRender = () => {
if (!config.condition) return true
return fieldValue === config.condition.value
}
const renderInput = () => {
switch (config.type) {
case 'short-input':
@@ -99,6 +108,10 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
}
}
if (!shouldRender()) {
return null
}
return (
<div className="space-y-1" onMouseDown={handleMouseDown}>
{config.type !== 'switch' && <Label>{config.title}</Label>}
+128
View File
@@ -0,0 +1,128 @@
import { GmailIcon } from '@/components/icons'
import { GmailToolResponse } from '@/tools/gmail/types'
import { BlockConfig } from '../types'
export const GmailBlock: BlockConfig<GmailToolResponse> = {
type: 'gmail_block',
toolbar: {
title: 'Gmail',
description: 'Send, read, and search Gmail messages',
bgColor: '#EA4335', // Gmail red color
icon: GmailIcon,
category: 'tools',
},
tools: {
access: ['gmail_send', 'gmail_read', 'gmail_search'],
config: {
tool: (params) => {
switch (params.operation) {
case 'send_gmail':
return 'gmail_send'
case 'read_gmail':
return 'gmail_read'
case 'search_gmail':
return 'gmail_search'
default:
throw new Error(`Invalid Gmail operation: ${params.operation}`)
}
},
},
},
workflow: {
inputs: {
operation: { type: 'string', required: true },
accessToken: { type: 'string', required: true },
// Send operation inputs
to: { type: 'string', required: false },
subject: { type: 'string', required: false },
body: { type: 'string', required: false },
// Read operation inputs
messageId: { type: 'string', required: false },
// Search operation inputs
query: { type: 'string', required: false },
maxResults: { type: 'number', required: false },
},
outputs: {
response: {
type: {
content: 'string',
metadata: 'json',
},
},
},
subBlocks: [
// Operation selector
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Send Email', id: 'send_gmail' },
{ label: 'Read Email', id: 'read_gmail' },
{ label: 'Search Emails', id: 'search_gmail' },
],
},
// OAuth Token
{
id: 'accessToken',
title: 'Access Token',
type: 'short-input',
layout: 'full',
placeholder: 'Enter Gmail OAuth token',
password: true,
},
// Send Email Fields
{
id: 'to',
title: 'To',
type: 'short-input',
layout: 'full',
placeholder: 'Recipient email address',
condition: { field: 'operation', value: 'send_gmail' },
},
{
id: 'subject',
title: 'Subject',
type: 'short-input',
layout: 'full',
placeholder: 'Email subject',
condition: { field: 'operation', value: 'send_gmail' },
},
{
id: 'body',
title: 'Body',
type: 'long-input',
layout: 'full',
placeholder: 'Email content',
condition: { field: 'operation', value: 'send_gmail' },
},
// Read Email Fields
{
id: 'messageId',
title: 'Message ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter message ID to read',
condition: { field: 'operation', value: 'read_gmail' },
},
// Search Fields
{
id: 'query',
title: 'Search Query',
type: 'short-input',
layout: 'full',
placeholder: 'Enter search terms',
condition: { field: 'operation', value: 'search_gmail' },
},
{
id: 'maxResults',
title: 'Max Results',
type: 'short-input',
layout: 'full',
placeholder: 'Maximum number of results (default: 10)',
condition: { field: 'operation', value: 'search_gmail' },
},
],
},
}
+6 -2
View File
@@ -15,7 +15,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
access: ['notion_read', 'notion_write'],
config: {
tool: (params) => {
return params.operation === 'write' ? 'notion_write' : 'notion_read'
return params.operation === 'write_notion' ? 'notion_write' : 'notion_read'
},
},
},
@@ -40,7 +40,10 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: ['read', 'write'],
options: [
{ label: 'Read Page', id: 'read_notion' },
{ label: 'Write Page', id: 'write_notion' },
],
},
{
id: 'pageId',
@@ -55,6 +58,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
type: 'long-input',
layout: 'full',
placeholder: 'Enter content to write (for write operation)',
condition: { field: 'operation', value: 'write_notion' },
},
{
id: 'apiKey',
+3
View File
@@ -6,6 +6,7 @@ import { CrewAIVisionBlock } from './blocks/crewai'
import { FirecrawlScrapeBlock } from './blocks/firecrawl'
import { FunctionBlock } from './blocks/function'
import { GitHubBlock } from './blocks/github'
import { GmailBlock } from './blocks/gmail'
import { JinaBlock } from './blocks/jina'
import { NotionBlock } from './blocks/notion'
import { RouterBlock } from './blocks/router'
@@ -34,6 +35,7 @@ export {
RouterBlock,
YouTubeSearchBlock,
NotionBlock,
GmailBlock,
}
// Registry of all block configurations
@@ -54,6 +56,7 @@ const blocks: Record<string, BlockConfig> = {
tavily_extract: TavilyExtractBlock,
youtube_search: YouTubeSearchBlock,
notion_reader: NotionBlock,
gmail_block: GmailBlock,
}
// Build a reverse mapping of tools to block types
+4
View File
@@ -78,6 +78,10 @@ export interface SubBlockConfig {
hidden?: boolean
value?: (params: Record<string, any>) => string
minimizable?: boolean
condition?: {
field: string
value: string | number | boolean
}
}
export interface BlockConfig<T extends ToolResponse = ToolResponse> {
+88
View File
@@ -0,0 +1,88 @@
import { ToolConfig } from '../types'
import { GmailMessage, GmailReadParams, GmailToolResponse } from './types'
const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
id: 'gmail_read',
name: 'Gmail Read',
description: 'Read emails from Gmail',
version: '1.0.0',
params: {
accessToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'OAuth access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'ID of the message to read',
},
},
request: {
url: (params: GmailReadParams) => `${GMAIL_API_BASE}/messages/${params.messageId}`,
method: 'GET',
headers: (params: GmailReadParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response) => {
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()
}
}
return {
success: true,
output: {
content: body,
metadata: {
id: message.id,
threadId: message.threadId,
labelIds: message.labelIds,
from,
to,
subject,
},
},
}
},
transformError: (error) => {
// Handle Google API error format
if (error.error?.message) {
if (error.error.message.includes('invalid authentication credentials')) {
return 'Invalid or expired access token. Please reauthenticate.'
}
if (error.error.message.includes('quota')) {
return 'Gmail API quota exceeded. Please try again later.'
}
return error.error.message
}
return error.message || 'An unexpected error occurred while reading email'
},
}
+82
View File
@@ -0,0 +1,82 @@
import { ToolConfig } from '../types'
import { GmailSearchParams, GmailToolResponse } from './types'
const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
export const gmailSearchTool: ToolConfig<GmailSearchParams, GmailToolResponse> = {
id: 'gmail_search',
name: 'Gmail Search',
description: 'Search emails in Gmail',
version: '1.0.0',
params: {
accessToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'OAuth access token for Gmail API',
},
query: {
type: 'string',
required: true,
description: 'Search query for emails',
},
maxResults: {
type: 'number',
required: false,
description: 'Maximum number of results to return',
},
},
request: {
url: (params: GmailSearchParams) => {
const searchParams = new URLSearchParams()
searchParams.append('q', params.query)
if (params.maxResults) {
searchParams.append('maxResults', params.maxResults.toString())
}
return `${GMAIL_API_BASE}/messages?${searchParams.toString()}`
},
method: 'GET',
headers: (params: GmailSearchParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to search emails')
}
return {
success: true,
output: {
content: `Found ${data.messages?.length || 0} messages`,
metadata: {
results:
data.messages?.map((msg: any) => ({
id: msg.id,
threadId: msg.threadId,
})) || [],
},
},
}
},
transformError: (error) => {
// Handle Google API error format
if (error.error?.message) {
if (error.error.message.includes('invalid authentication credentials')) {
return 'Invalid or expired access token. Please reauthenticate.'
}
if (error.error.message.includes('quota')) {
return 'Gmail API quota exceeded. Please try again later.'
}
return error.error.message
}
return error.message || 'An unexpected error occurred while searching emails'
},
}
+93
View File
@@ -0,0 +1,93 @@
import { ToolConfig } from '../types'
import { GmailSendParams, GmailToolResponse } from './types'
const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
export const gmailSendTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
id: 'gmail_send',
name: 'Gmail Send',
description: 'Send emails using Gmail',
version: '1.0.0',
params: {
accessToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'OAuth access token for Gmail API',
},
to: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'Recipient email address',
},
subject: {
type: 'string',
required: true,
description: 'Email subject',
},
body: {
type: 'string',
required: true,
description: 'Email body content',
},
},
request: {
url: () => `${GMAIL_API_BASE}/messages/send`,
method: 'POST',
headers: (params: GmailSendParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params: GmailSendParams): Record<string, any> => {
const email = [
'Content-Type: text/plain; charset="UTF-8"',
'MIME-Version: 1.0',
`To: ${params.to}`,
`Subject: ${params.subject}`,
'',
params.body,
].join('\n')
return {
raw: Buffer.from(email).toString('base64url'),
}
},
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to send email')
}
return {
success: true,
output: {
content: 'Email sent successfully',
metadata: {
id: data.id,
threadId: data.threadId,
labelIds: data.labelIds,
},
},
}
},
transformError: (error) => {
// Handle Google API error format
if (error.error?.message) {
if (error.error.message.includes('invalid authentication credentials')) {
return 'Invalid or expired access token. Please reauthenticate.'
}
if (error.error.message.includes('quota')) {
return 'Gmail API quota exceeded. Please try again later.'
}
return error.error.message
}
return error.message || 'An unexpected error occurred while sending email'
},
}
+78
View File
@@ -0,0 +1,78 @@
import { ToolResponse } from '../types'
// Base parameters shared by all operations
interface BaseGmailParams {
accessToken: string
}
// Send operation parameters
export interface GmailSendParams extends BaseGmailParams {
to: string
subject: string
body: string
}
// Read operation parameters
export interface GmailReadParams extends BaseGmailParams {
messageId: string
}
// Search operation parameters
export interface GmailSearchParams extends BaseGmailParams {
query: string
maxResults?: number
}
// Union type for all Gmail tool parameters
export type GmailToolParams = GmailSendParams | GmailReadParams | GmailSearchParams
// Response metadata
interface BaseGmailMetadata {
id?: string
threadId?: string
labelIds?: string[]
}
interface EmailMetadata extends BaseGmailMetadata {
from?: string
to?: string
subject?: string
}
interface SearchMetadata extends BaseGmailMetadata {
results: Array<{
id: string
threadId: string
}>
}
// Response format
export interface GmailToolResponse extends ToolResponse {
output: {
content: string
metadata: EmailMetadata | SearchMetadata
}
}
// Email Message Interface
export interface GmailMessage {
id: string
threadId: string
labelIds: string[]
snippet: string
payload: {
headers: Array<{
name: string
value: string
}>
body: {
data?: string
}
parts?: Array<{
mimeType: string
body: {
data?: string
}
}>
}
}
+6
View File
@@ -5,6 +5,9 @@ import { reasonerTool as deepseekReasoner } from './deepseek/reasoner'
import { scrapeTool } from './firecrawl/scrape'
import { functionExecuteTool as functionExecute } from './function/execute'
import { repoInfoTool } from './github/repo'
import { gmailReadTool } from './gmail/read'
import { gmailSearchTool } from './gmail/search'
import { gmailSendTool } from './gmail/send'
import { chatTool as googleChat } from './google/chat'
import { requestTool as httpRequest } from './http/request'
import { contactsTool as hubspotContacts } from './hubspot/contacts'
@@ -44,6 +47,9 @@ export const tools: Record<string, ToolConfig> = {
youtube_search: youtubeSearchTool,
notion_read: notionReadTool,
notion_write: notionWriteTool,
gmail_send: gmailSendTool,
gmail_read: gmailReadTool,
gmail_search: gmailSearchTool,
}
// Get a tool by its ID