From 79b761c022410d0056ca6173ebe78816d46f594c Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Sat, 24 May 2025 20:57:49 -0700 Subject: [PATCH] feat(tools): created outlook tools/block (#409) * feat: first round of tools for outlook * added more * outlook finished * added bun and docs * fix: added greptile comments * added greptile and bun lint * got rid of HTML --------- Co-authored-by: Adam Gough --- apps/docs/content/docs/tools/meta.json | 1 + .../content/docs/tools/microsoft_teams.mdx | 89 +++++++--- apps/docs/content/docs/tools/outlook.mdx | 166 ++++++++++++++++++ .../api/auth/oauth/outlook/folders/route.ts | 132 ++++++++++++++ .../components/oauth-required-modal.tsx | 4 + .../credential-selector.tsx | 1 - .../folder-selector/folder-selector.tsx | 87 +++++---- apps/sim/blocks/blocks/outlook.ts | 150 ++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 115 ++++++++++++ apps/sim/lib/auth.ts | 25 +++ apps/sim/lib/oauth.ts | 35 ++++ apps/sim/tools/outlook/draft.ts | 112 ++++++++++++ apps/sim/tools/outlook/index.ts | 5 + apps/sim/tools/outlook/read.ts | 146 +++++++++++++++ apps/sim/tools/outlook/send.ts | 111 ++++++++++++ apps/sim/tools/outlook/types.ts | 129 ++++++++++++++ apps/sim/tools/registry.ts | 4 + 18 files changed, 1252 insertions(+), 62 deletions(-) create mode 100644 apps/docs/content/docs/tools/outlook.mdx create mode 100644 apps/sim/app/api/auth/oauth/outlook/folders/route.ts create mode 100644 apps/sim/blocks/blocks/outlook.ts create mode 100644 apps/sim/tools/outlook/draft.ts create mode 100644 apps/sim/tools/outlook/index.ts create mode 100644 apps/sim/tools/outlook/read.ts create mode 100644 apps/sim/tools/outlook/send.ts create mode 100644 apps/sim/tools/outlook/types.ts diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index 166703b7e..f849bd071 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -27,6 +27,7 @@ "microsoft_teams", "notion", "openai", + "outlook", "perplexity", "pinecone", "reddit", diff --git a/apps/docs/content/docs/tools/microsoft_teams.mdx b/apps/docs/content/docs/tools/microsoft_teams.mdx index 6c7f92d43..1e5e76316 100644 --- a/apps/docs/content/docs/tools/microsoft_teams.mdx +++ b/apps/docs/content/docs/tools/microsoft_teams.mdx @@ -9,30 +9,70 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" type="microsoft_teams" color="#E0E0E0" icon={true} - iconSvg={` - - - - - - - - - - - - - - - - - - - + iconSvg={` + + + + + + + + + + + + + + + + + + + `} /> @@ -52,6 +92,7 @@ With Microsoft Teams, you can: In Sim Studio, the Microsoft Teams integration enables your agents to interact directly with chat messages programmatically. This allows for powerful automation scenarios such as sending updates, posting alerts, coordinating tasks, and responding to conversations in real time. Your agents can write new messages to chats or channels, update content based on workflow data, and engage with users where collaboration happens. By integrating Sim Studio with Microsoft Teams, you bridge the gap between intelligent workflows and team communication — empowering your agents to streamline collaboration, automate communication tasks, and keep your teams aligned. {/* MANUAL-CONTENT-END */} + ## Usage Instructions Integrate Microsoft Teams functionality to manage messages. Read content from existing messages and write to messages using OAuth authentication. Supports text content manipulation for message creation and editing. diff --git a/apps/docs/content/docs/tools/outlook.mdx b/apps/docs/content/docs/tools/outlook.mdx new file mode 100644 index 000000000..fa7415c8a --- /dev/null +++ b/apps/docs/content/docs/tools/outlook.mdx @@ -0,0 +1,166 @@ +--- +title: Outlook +description: Access Outlook +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[Microsoft Outlook](https://outlook.office365.com) is a comprehensive email and calendar platform that helps users manage communications, schedules, and tasks efficiently. As part of Microsoft's productivity suite, Outlook offers robust tools for sending and organizing emails, coordinating meetings, and integrating seamlessly with Microsoft 365 applications — enabling individuals and teams to stay organized and connected across devices. + +With Microsoft Outlook, you can: + +- **Send and receive emails**: Communicate clearly and professionally with individuals or distribution lists +- **Manage calendars and events**: Schedule meetings, set reminders, and view availability +- **Organize your inbox**: Use folders, categories, and rules to keep your email streamlined +- **Access contacts and tasks**: Keep track of key people and action items in one place +- **Integrate with Microsoft 365**: Work seamlessly with Word, Excel, Teams, and other Microsoft apps +- **Access across devices**: Use Outlook on desktop, web, and mobile with real-time sync +- **Maintain privacy and security**: Leverage enterprise-grade encryption and compliance controls + +In Sim Studio, the Microsoft Outlook integration enables your agents to interact directly with email and calendar data programmatically. This allows for powerful automation scenarios such as sending custom email updates, parsing incoming messages for workflow triggers, creating calendar events, and managing task reminders. By connecting Sim Studio with Microsoft Outlook, you enable intelligent agents to automate communications, streamline scheduling, and maintain visibility into organizational correspondence — all within your workflow ecosystem. +{/* MANUAL-CONTENT-END */} + +## Usage Instructions + +Integrate Outlook functionality to read, draft, and send email messages within your workflow. Automate email communications and process email content using OAuth authentication. + + + +## Tools + +### `outlook_send` + +Send emails using Outlook + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Access token for Outlook API | +| `to` | string | Yes | Recipient email address | +| `subject` | string | Yes | Email subject | +| `body` | string | Yes | Email body content | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `message` | string | +| `results` | string | +| `timestamp` | string | + +### `outlook_draft` + +Draft emails using Outlook + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Access token for Outlook API | +| `to` | string | Yes | Recipient email address | +| `subject` | string | Yes | Email subject | +| `body` | string | Yes | Email body content | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `message` | string | +| `results` | string | +| `subject` | string | +| `status` | string | +| `timestamp` | string | + +### `outlook_read` + +Read emails from Outlook + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | OAuth access token for Outlook | +| `folder` | string | No | Folder ID to read emails from \(default: Inbox\) | +| `maxResults` | number | No | Maximum number of emails to retrieve \(default: 1, max: 10\) | +| `messageId` | string | No | Message ID to read | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `message` | string | +| `results` | json | + + + +## Block Configuration + +### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `operation` | string | Yes | Operation | + + + +### Outputs + +| Output | Type | Description | +| ------ | ---- | ----------- | +| `response` | object | Output from response | +| ↳ `message` | string | message of the response | +| ↳ `results` | json | results of the response | + + +## Notes + +- Category: `tools` +- Type: `outlook` diff --git a/apps/sim/app/api/auth/oauth/outlook/folders/route.ts b/apps/sim/app/api/auth/oauth/outlook/folders/route.ts new file mode 100644 index 000000000..df0897ebb --- /dev/null +++ b/apps/sim/app/api/auth/oauth/outlook/folders/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { refreshAccessTokenIfNeeded } from '../../utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OutlookFoldersAPI') + +interface OutlookFolder { + id: string + displayName: string + totalItemCount?: number + unreadItemCount?: number +} + +export async function GET(request: Request) { + try { + const session = await getSession() + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + + if (!credentialId) { + logger.error('Missing credentialId in request') + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } + + try { + // Get the userId from the session + const userId = session?.user?.id || '' + + if (!userId) { + logger.error('No user ID found in session') + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + userId, + crypto.randomUUID().slice(0, 8) + ) + + if (!accessToken) { + logger.error('Failed to get access token', { credentialId, userId }) + return NextResponse.json( + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } + ) + } + + const response = await fetch('https://graph.microsoft.com/v1.0/me/mailFolders', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json() + logger.error('Microsoft Graph API error getting folders', { + status: response.status, + error: errorData, + endpoint: 'https://graph.microsoft.com/v1.0/me/mailFolders', + }) + + // Check for auth errors specifically + if (response.status === 401) { + return NextResponse.json( + { + error: 'Authentication failed. Please reconnect your Outlook account.', + authRequired: true, + }, + { status: 401 } + ) + } + + throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) + } + + const data = await response.json() + const folders = data.value || [] + + // Transform folders to match the expected format + const transformedFolders = folders.map((folder: OutlookFolder) => ({ + id: folder.id, + name: folder.displayName, + type: 'folder', + messagesTotal: folder.totalItemCount || 0, + messagesUnread: folder.unreadItemCount || 0, + })) + + return NextResponse.json({ + folders: transformedFolders, + }) + } catch (innerError) { + logger.error('Error during API requests:', innerError) + + // Check if it's an authentication error + const errorMessage = innerError instanceof Error ? innerError.message : String(innerError) + if ( + errorMessage.includes('auth') || + errorMessage.includes('token') || + errorMessage.includes('unauthorized') || + errorMessage.includes('unauthenticated') + ) { + return NextResponse.json( + { + error: 'Authentication failed. Please reconnect your Outlook account.', + authRequired: true, + details: errorMessage, + }, + { status: 401 } + ) + } + + throw innerError + } + } catch (error) { + logger.error('Error processing Outlook folders request:', error) + return NextResponse.json( + { + error: 'Failed to retrieve Outlook folders', + details: (error as Error).message, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 2c7ef7fdd..59fbd6798 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -97,6 +97,10 @@ const SCOPE_DESCRIPTIONS: Record = { 'Group.Read.All': 'Read your Microsoft groups', 'Group.ReadWrite.All': 'Write to your Microsoft groups', 'Team.ReadBasic.All': 'Read your Microsoft teams', + 'Mail.ReadWrite': 'Write to your Microsoft emails', + 'Mail.ReadBasic': 'Read your Microsoft emails', + 'Mail.Read': 'Read your Microsoft emails', + 'Mail.Send': 'Send emails on your behalf', identify: 'Read your Discord user', bot: 'Read your Discord bot', 'messages.read': 'Read your Discord messages', diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx index 94cf46f42..0420835b4 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx @@ -168,7 +168,6 @@ export function CredentialSelector({ if (!baseProviderConfig) { return } - // Always use the base provider icon for a more consistent UI return baseProviderConfig.icon({ className: 'h-4 w-4' }) } diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx index 85e0220e2..c86334540 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, RefreshCw } from 'lucide-react' -import { GmailIcon } from '@/components/icons' +import { GmailIcon, OutlookIcon } from '@/components/icons' import { Button } from '@/components/ui/button' import { Command, @@ -112,7 +112,7 @@ export function FolderSelector({ // 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 + if (!selectedCredentialId || !folderId || provider === 'outlook') return null setIsLoadingSelectedFolder(true) try { @@ -144,10 +144,10 @@ export function FolderSelector({ setIsLoadingSelectedFolder(false) } }, - [selectedCredentialId, onFolderInfoChange] + [selectedCredentialId, onFolderInfoChange, provider] ) - // Fetch folders from Gmail + // Fetch folders from Gmail or Outlook const fetchFolders = useCallback( async (searchQuery?: string) => { if (!selectedCredentialId) return @@ -163,22 +163,32 @@ export function FolderSelector({ queryParams.append('query', searchQuery) } - const response = await fetch(`/api/auth/oauth/gmail/labels?${queryParams.toString()}`) + // Determine the API endpoint based on provider + let apiEndpoint: string + if (provider === 'outlook') { + apiEndpoint = `/api/auth/oauth/outlook/folders?${queryParams.toString()}` + } else { + // Default to Gmail + apiEndpoint = `/api/auth/oauth/gmail/labels?${queryParams.toString()}` + } + + const response = await fetch(apiEndpoint) if (response.ok) { const data = await response.json() - setFolders(data.labels || []) + const folderList = provider === 'outlook' ? data.folders : data.labels + setFolders(folderList || []) // If we have a selected folder ID, find the folder info if (selectedFolderId) { - const folderInfo = data.labels.find( + const folderInfo = folderList.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 + } else if (!searchQuery && provider !== 'outlook') { + // Only try to fetch by ID for Gmail if this is not a search query // and we couldn't find the folder in the list fetchFolderById(selectedFolderId) } @@ -196,7 +206,7 @@ export function FolderSelector({ setIsLoading(false) } }, - [selectedCredentialId, selectedFolderId, onFolderInfoChange, fetchFolderById] + [selectedCredentialId, selectedFolderId, onFolderInfoChange, fetchFolderById, provider] ) // Fetch credentials on initial mount @@ -221,12 +231,12 @@ export function FolderSelector({ } }, [value]) - // Fetch the selected folder metadata once credentials are ready + // Fetch the selected folder metadata once credentials are ready (Gmail only) useEffect(() => { - if (value && selectedCredentialId && !selectedFolder) { + if (value && selectedCredentialId && !selectedFolder && provider !== 'outlook') { fetchFolderById(value) } - }, [value, selectedCredentialId, selectedFolder, fetchFolderById]) + }, [value, selectedCredentialId, selectedFolder, fetchFolderById, provider]) // Handle folder selection const handleSelectFolder = (folder: FolderInfo) => { @@ -263,7 +273,23 @@ export function FolderSelector({ const getFolderIcon = (size: 'sm' | 'md' = 'sm') => { const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5' - return + if (provider === 'gmail') { + return + } + if (provider === 'outlook') { + return + } + return null + } + + const getProviderName = () => { + if (provider === 'outlook') return 'Outlook' + return 'Gmail' + } + + const getFolderLabel = () => { + if (provider === 'outlook') return 'folders' + return 'labels' } return ( @@ -283,11 +309,6 @@ export function FolderSelector({ {getFolderIcon('sm')} {selectedFolder.name} - ) : selectedFolderId && (isLoadingSelectedFolder || !selectedCredentialId) ? ( -
- - Loading label... -
) : (
{getFolderIcon('sm')} @@ -321,24 +342,27 @@ export function FolderSelector({ )} - + {isLoading ? (
- Loading labels... + Loading {getFolderLabel()}...
) : credentials.length === 0 ? (

No accounts connected.

- Connect a Gmail account to continue. + Connect a {getProviderName()} account to continue.

) : (
-

No labels found.

+

No {getFolderLabel()} found.

Try a different search or account.

@@ -371,7 +395,7 @@ export function FolderSelector({ {folders.length > 0 && (
- Labels + {getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
{folders.map((folder) => (
- Connect Gmail account + Connect {getProviderName()} account
)} - - {/* Add another account option */} - {/* {credentials.length > 0 && ( - - -
- Connect Another Account -
-
-
- )} */} @@ -421,7 +434,7 @@ export function FolderSelector({ isOpen={showOAuthModal} onClose={() => setShowOAuthModal(false)} provider={provider} - toolName='Gmail' + toolName={getProviderName()} requiredScopes={requiredScopes} serviceId={getServiceId()} /> diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts new file mode 100644 index 000000000..4c2c82473 --- /dev/null +++ b/apps/sim/blocks/blocks/outlook.ts @@ -0,0 +1,150 @@ +import { OutlookIcon } from '@/components/icons' +import type { + OutlookDraftResponse, + OutlookReadResponse, + OutlookSendResponse, +} from '@/tools/outlook/types' +import type { BlockConfig } from '../types' + +export const OutlookBlock: BlockConfig< + OutlookReadResponse | OutlookSendResponse | OutlookDraftResponse +> = { + type: 'outlook', + name: 'Outlook', + description: 'Access Outlook', + longDescription: + 'Integrate Outlook functionality to read, draft, andsend email messages within your workflow. Automate email communications and process email content using OAuth authentication.', + docsLink: 'https://docs.simstudio.ai/tools/outlook', + category: 'tools', + bgColor: '#E0E0E0', + icon: OutlookIcon, + subBlocks: [ + // Operation selector + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Send Email', id: 'send_outlook' }, + { label: 'Draft Email', id: 'draft_outlook' }, + { label: 'Read Email', id: 'read_outlook' }, + ], + }, + // Gmail Credentials + { + id: 'credential', + title: 'Microsoft Account', + type: 'oauth-input', + layout: 'full', + provider: 'outlook', + serviceId: 'outlook', + requiredScopes: [ + 'Mail.ReadWrite', + 'Mail.ReadBasic', + 'Mail.Read', + 'Mail.Send', + 'offline_access', + 'openid', + 'profile', + 'email', + ], + placeholder: 'Select Microsoft account', + }, + // Send Email Fields + { + id: 'to', + title: 'To', + type: 'short-input', + layout: 'full', + placeholder: 'Recipient email address', + condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] }, + }, + { + id: 'subject', + title: 'Subject', + type: 'short-input', + layout: 'full', + placeholder: 'Email subject', + condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] }, + }, + { + id: 'body', + title: 'Body', + type: 'long-input', + layout: 'full', + placeholder: 'Email content', + condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] }, + }, + // Read Email Fields - Add folder selector + { + id: 'folder', + title: 'Folder', + type: 'folder-selector', + layout: 'full', + provider: 'outlook', + serviceId: 'outlook', + requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'], + placeholder: 'Select Outlook folder', + condition: { field: 'operation', value: 'read_outlook' }, + }, + { + 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_outlook' }, + }, + ], + tools: { + access: ['outlook_send', 'outlook_draft', 'outlook_read'], + config: { + tool: (params) => { + switch (params.operation) { + case 'send_outlook': + return 'outlook_send' + case 'read_outlook': + return 'outlook_read' + case 'draft_outlook': + return 'outlook_draft' + default: + throw new Error(`Invalid Outlook operation: ${params.operation}`) + } + }, + 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_outlook' && !rest.folder) { + rest.folder = 'INBOX' + } + + return { + ...rest, + credential, // Keep the credential parameter + } + }, + }, + }, + inputs: { + operation: { type: 'string', required: true }, + credential: { 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 + folder: { type: 'string', required: false }, + maxResults: { type: 'number', required: false }, + }, + outputs: { + response: { + type: { + message: 'string', + results: 'json', + }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 86f698af8..4571e9910 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -36,6 +36,7 @@ import { MicrosoftTeamsBlock } from './blocks/microsoft_teams' import { MistralParseBlock } from './blocks/mistral_parse' import { NotionBlock } from './blocks/notion' import { OpenAIBlock } from './blocks/openai' +import { OutlookBlock } from './blocks/outlook' import { PerplexityBlock } from './blocks/perplexity' import { PineconeBlock } from './blocks/pinecone' import { RedditBlock } from './blocks/reddit' @@ -92,6 +93,7 @@ export const registry: Record = { mistral_parse: MistralParseBlock, notion: NotionBlock, openai: OpenAIBlock, + outlook: OutlookBlock, perplexity: PerplexityBlock, pinecone: PineconeBlock, reddit: RedditBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 0d0b8e26c..66f456898 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2556,3 +2556,118 @@ export function MicrosoftTeamsIcon(props: SVGProps) { ) } + +export function OutlookIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index f6c3d6951..2963b2261 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -397,6 +397,31 @@ export const auth = betterAuth({ redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-teams`, }, + { + providerId: 'outlook', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: [ + 'openid', + 'profile', + 'email', + 'Mail.ReadWrite', + 'Mail.ReadBasic', + 'Mail.Read', + 'Mail.Send', + 'offline_access', + ], + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + pkce: true, + redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/outlook`, + }, + // Supabase provider { providerId: 'supabase', diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index 1262e68dd..ba295661b 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -14,6 +14,7 @@ import { MicrosoftIcon, MicrosoftTeamsIcon, NotionIcon, + OutlookIcon, SupabaseIcon, xIcon, } from '@/components/icons' @@ -51,6 +52,7 @@ export type OAuthService = | 'jira' | 'discord' | 'microsoft-teams' + | 'outlook' // Define the interface for OAuth provider configuration export interface OAuthProviderConfig { id: OAuthProvider @@ -163,6 +165,24 @@ export const OAUTH_PROVIDERS: Record = { 'offline_access', ], }, + outlook: { + id: 'outlook', + name: 'Outlook', + description: 'Connect to Outlook and manage emails.', + providerId: 'outlook', + icon: (props) => OutlookIcon(props), + baseProviderIcon: (props) => MicrosoftIcon(props), + scopes: [ + 'openid', + 'profile', + 'email', + 'Mail.ReadWrite', + 'Mail.ReadBasic', + 'Mail.Read', + 'Mail.Send', + 'offline_access', + ], + }, }, defaultService: 'microsoft', }, @@ -356,6 +376,8 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[] } } else if (provider === 'microsoft-teams') { return 'microsoft-teams' + } else if (provider === 'outlook') { + return 'outlook' } else if (provider === 'github') { return 'github' } else if (provider === 'supabase') { @@ -412,6 +434,14 @@ export interface ProviderConfig { * This is a server-safe utility that can be used in both client and server code */ export function parseProvider(provider: OAuthProvider): ProviderConfig { + // Handle special cases first + if (provider === 'outlook') { + return { + baseProvider: 'microsoft', + featureType: 'outlook', + } + } + // Handle compound providers (e.g., 'google-email' -> { baseProvider: 'google', featureType: 'email' }) const [base, feature] = provider.split('-') @@ -506,6 +536,11 @@ export async function refreshOAuthToken( clientId = env.MICROSOFT_CLIENT_ID clientSecret = env.MICROSOFT_CLIENT_SECRET break + case 'outlook': + tokenEndpoint = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' + clientId = env.MICROSOFT_CLIENT_ID + clientSecret = env.MICROSOFT_CLIENT_SECRET + break default: throw new Error(`Unsupported provider: ${provider}`) } diff --git a/apps/sim/tools/outlook/draft.ts b/apps/sim/tools/outlook/draft.ts new file mode 100644 index 000000000..677ef488f --- /dev/null +++ b/apps/sim/tools/outlook/draft.ts @@ -0,0 +1,112 @@ +import type { ToolConfig } from '../types' +import type { OutlookDraftParams, OutlookDraftResponse } from './types' + +export const outlookDraftTool: ToolConfig = { + id: 'outlook_draft', + name: 'Outlook Draft', + description: 'Draft emails using Outlook', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + description: 'Access token for Outlook API', + }, + to: { + type: 'string', + required: true, + description: 'Recipient email address', + }, + subject: { + type: 'string', + required: true, + description: 'Email subject', + }, + body: { + type: 'string', + required: true, + description: 'Email body content', + }, + }, + + request: { + url: (params) => { + return `https://graph.microsoft.com/v1.0/me/messages` + }, + method: 'POST', + headers: (params) => { + // Validate access token + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params: OutlookDraftParams): Record => { + return { + subject: params.subject, + body: { + contentType: 'Text', + content: params.body, + }, + toRecipients: [ + { + emailAddress: { + address: params.to, + }, + }, + ], + } + }, + }, + transformResponse: async (response) => { + if (!response.ok) { + let errorData + try { + errorData = await response.json() + } catch { + throw new Error('Failed to draft email') + } + throw new Error(errorData.error?.message || 'Failed to draft email') + } + + // Outlook draft API returns the created message object + const data = await response.json() + + return { + success: true, + output: { + message: 'Email drafted successfully', + results: { + id: data.id, + subject: data.subject, + status: 'drafted', + timestamp: new Date().toISOString(), + }, + }, + } + }, + + transformError: (error) => { + // Handle Outlook 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 'Outlook API quota exceeded. Please try again later.' + } + return error.error.message + } + return error.message || 'An unexpected error occurred while drafting email' + }, +} diff --git a/apps/sim/tools/outlook/index.ts b/apps/sim/tools/outlook/index.ts new file mode 100644 index 000000000..6cd4ed142 --- /dev/null +++ b/apps/sim/tools/outlook/index.ts @@ -0,0 +1,5 @@ +import { outlookDraftTool } from './draft' +import { outlookReadTool } from './read' +import { outlookSendTool } from './send' + +export { outlookDraftTool, outlookReadTool, outlookSendTool } diff --git a/apps/sim/tools/outlook/read.ts b/apps/sim/tools/outlook/read.ts new file mode 100644 index 000000000..3830bb3b8 --- /dev/null +++ b/apps/sim/tools/outlook/read.ts @@ -0,0 +1,146 @@ +import type { ToolConfig } from '../types' +import type { + CleanedOutlookMessage, + OutlookMessage, + OutlookMessagesResponse, + OutlookReadParams, + OutlookReadResponse, +} from './types' + +export const outlookReadTool: ToolConfig = { + id: 'outlook_read', + name: 'Outlook Read', + description: 'Read emails from Outlook', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + params: { + accessToken: { + type: 'string', + required: true, + description: 'OAuth access token for Outlook', + }, + folder: { + type: 'string', + required: false, + description: 'Folder ID to read emails from (default: Inbox)', + }, + maxResults: { + type: 'number', + required: false, + description: 'Maximum number of emails to retrieve (default: 1, max: 10)', + }, + }, + request: { + url: (params) => { + // Set max results (default to 1 for simplicity, max 10) with no negative values + const maxResults = params.maxResults + ? Math.max(1, Math.min(Math.abs(params.maxResults), 10)) + : 1 + + // If folder is provided, read from that specific folder + if (params.folder) { + return `https://graph.microsoft.com/v1.0/me/mailFolders/${params.folder}/messages?$top=${maxResults}&$orderby=createdDateTime desc` + } + + // Otherwise fetch from all messages (default behavior) + return `https://graph.microsoft.com/v1.0/me/messages?$top=${maxResults}&$orderby=createdDateTime desc` + }, + method: 'GET', + headers: (params) => { + // Validate access token + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to read Outlook mail: ${errorText}`) + } + + const data: OutlookMessagesResponse = await response.json() + + // Microsoft Graph API returns messages in a 'value' array + const messages = data.value || [] + + if (messages.length === 0) { + return { + success: true, + output: { + message: 'No mail found.', + results: [], + }, + } + } + + // Clean up the message data to only include essential fields + const cleanedMessages: CleanedOutlookMessage[] = messages.map((message: OutlookMessage) => ({ + id: message.id, + subject: message.subject, + bodyPreview: message.bodyPreview, + body: { + contentType: message.body?.contentType, + content: message.body?.content, + }, + sender: { + name: message.sender?.emailAddress?.name, + address: message.sender?.emailAddress?.address, + }, + from: { + name: message.from?.emailAddress?.name, + address: message.from?.emailAddress?.address, + }, + toRecipients: + message.toRecipients?.map((recipient) => ({ + name: recipient.emailAddress?.name, + address: recipient.emailAddress?.address, + })) || [], + ccRecipients: + message.ccRecipients?.map((recipient) => ({ + name: recipient.emailAddress?.name, + address: recipient.emailAddress?.address, + })) || [], + receivedDateTime: message.receivedDateTime, + sentDateTime: message.sentDateTime, + hasAttachments: message.hasAttachments, + isRead: message.isRead, + importance: message.importance, + })) + + return { + success: true, + output: { + message: `Successfully read ${cleanedMessages.length} email(s).`, + results: cleanedMessages, + }, + } + }, + transformError: (error) => { + // If it's an Error instance with a message, use that + if (error instanceof Error) { + return error.message + } + + // If it's an object with an error or message property + if (typeof error === 'object' && error !== null) { + if (error.error) { + return typeof error.error === 'string' ? error.error : JSON.stringify(error.error) + } + if (error.message) { + return error.message + } + } + + // Default fallback message + return 'An error occurred while reading Outlook email' + }, +} diff --git a/apps/sim/tools/outlook/send.ts b/apps/sim/tools/outlook/send.ts new file mode 100644 index 000000000..f7ed64325 --- /dev/null +++ b/apps/sim/tools/outlook/send.ts @@ -0,0 +1,111 @@ +import type { ToolConfig } from '../types' +import type { OutlookSendParams, OutlookSendResponse } from './types' + +export const outlookSendTool: ToolConfig = { + id: 'outlook_send', + name: 'Outlook Send', + description: 'Send emails using Outlook', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + description: 'Access token for Outlook API', + }, + to: { + type: 'string', + required: true, + description: 'Recipient email address', + }, + subject: { + type: 'string', + required: true, + description: 'Email subject', + }, + body: { + type: 'string', + required: true, + description: 'Email body content', + }, + }, + + request: { + url: (params) => { + return `https://graph.microsoft.com/v1.0/me/sendMail` + }, + method: 'POST', + headers: (params) => { + // Validate access token + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params: OutlookSendParams): Record => { + return { + message: { + subject: params.subject, + body: { + contentType: 'Text', + content: params.body, + }, + toRecipients: [ + { + emailAddress: { + address: params.to, + }, + }, + ], + }, + saveToSentItems: true, + } + }, + }, + transformResponse: async (response) => { + if (!response.ok) { + let errorData + try { + errorData = await response.json() + } catch { + throw new Error('Failed to send email') + } + throw new Error(errorData.error?.message || 'Failed to send email') + } + + // Outlook sendMail API returns empty body on success + return { + success: true, + output: { + message: 'Email sent successfully', + results: { + status: 'sent', + timestamp: new Date().toISOString(), + }, + }, + } + }, + + transformError: (error) => { + // Handle Outlook 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 'Outlook API quota exceeded. Please try again later.' + } + return error.error.message + } + return error.message || 'An unexpected error occurred while sending email' + }, +} diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts new file mode 100644 index 000000000..5ad7cae54 --- /dev/null +++ b/apps/sim/tools/outlook/types.ts @@ -0,0 +1,129 @@ +import type { ToolResponse } from '../types' + +export interface OutlookSendParams { + accessToken: string + to: string + subject: string + body: string +} + +export interface OutlookSendResponse extends ToolResponse { + output: { + message: string + results: any + } +} + +export interface OutlookReadParams { + accessToken: string + folder: string + maxResults: number + messageId?: string +} + +export interface OutlookReadResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookMessage[] + } +} + +export interface OutlookDraftParams { + accessToken: string + to: string + subject: string + body: string +} + +export interface OutlookDraftResponse extends ToolResponse { + output: { + message: string + results: any + } +} + +// Outlook API response interfaces +export interface OutlookEmailAddress { + name?: string + address: string +} + +export interface OutlookRecipient { + emailAddress: OutlookEmailAddress +} + +export interface OutlookMessageBody { + contentType?: string + content?: string +} + +export interface OutlookMessage { + id: string + subject?: string + bodyPreview?: string + body?: OutlookMessageBody + sender?: OutlookRecipient + from?: OutlookRecipient + toRecipients?: OutlookRecipient[] + ccRecipients?: OutlookRecipient[] + bccRecipients?: OutlookRecipient[] + receivedDateTime?: string + sentDateTime?: string + hasAttachments?: boolean + isRead?: boolean + importance?: string + // Add other common fields + '@odata.etag'?: string + createdDateTime?: string + lastModifiedDateTime?: string + changeKey?: string + categories?: string[] + internetMessageId?: string + parentFolderId?: string + conversationId?: string + conversationIndex?: string + isDeliveryReceiptRequested?: boolean | null + isReadReceiptRequested?: boolean + isDraft?: boolean + webLink?: string + inferenceClassification?: string + replyTo?: OutlookRecipient[] +} + +export interface OutlookMessagesResponse { + '@odata.context'?: string + '@odata.nextLink'?: string + value: OutlookMessage[] +} + +// Cleaned message interface for our response +export interface CleanedOutlookMessage { + id: string + subject?: string + bodyPreview?: string + body?: { + contentType?: string + content?: string + } + sender?: { + name?: string + address?: string + } + from?: { + name?: string + address?: string + } + toRecipients: Array<{ + name?: string + address?: string + }> + ccRecipients: Array<{ + name?: string + address?: string + }> + receivedDateTime?: string + sentDateTime?: string + hasAttachments?: boolean + isRead?: boolean + importance?: string +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 83578a844..46b6aeebf 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -57,6 +57,7 @@ import { import { mistralParserTool } from './mistral' import { notionReadTool, notionWriteTool } from './notion' import { imageTool, embeddingsTool as openAIEmbeddings } from './openai' +import { outlookDraftTool, outlookReadTool, outlookSendTool } from './outlook' import { perplexityChatTool } from './perplexity' import { pineconeFetchTool, @@ -183,4 +184,7 @@ export const tools: Record = { microsoft_teams_write_chat: microsoftTeamsWriteChatTool, microsoft_teams_read_channel: microsoftTeamsReadChannelTool, microsoft_teams_write_channel: microsoftTeamsWriteChannelTool, + outlook_read: outlookReadTool, + outlook_send: outlookSendTool, + outlook_draft: outlookDraftTool, }