diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index f7341289fe2..5b6d701c120 100644 --- a/apps/docs/content/docs/en/integrations/outlook.mdx +++ b/apps/docs/content/docs/en/integrations/outlook.mdx @@ -1,6 +1,6 @@ --- title: Outlook -description: Send, read, search, reply, organize, and manage Outlook email +description: Send, read, search, reply, organize, and manage Outlook email and calendar --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -37,7 +37,7 @@ By connecting Sim with Microsoft Outlook, you enable intelligent agents to autom ## Usage Instructions -Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can be used in trigger mode to trigger a workflow when a new email is received. +Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can also list, create, update, delete, and respond to calendar events. Can be used in trigger mode to trigger a workflow when a new email is received. @@ -473,6 +473,232 @@ Get a single attachment on an Outlook message, including its file contents | ↳ `lastModifiedDateTime` | string | When the attachment was last modified \(ISO 8601\) | | `attachments` | file[] | The downloaded file attachment \(empty for non-file attachment types\) | +### `outlook_calendar_list_events` + +List Outlook calendar events within a start/end time window + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | ID of the calendar to read. Defaults to the mailbox default calendar. Calendars shared by another user are not supported and may return 403. | +| `startDateTime` | string | No | Start of the time window \(ISO 8601, e.g. 2025-06-03T00:00:00-08:00\). Interpreted as UTC if no offset is given. Required unless paging with pageToken. | +| `endDateTime` | string | No | End of the time window \(ISO 8601, e.g. 2025-06-10T00:00:00-08:00\). Interpreted as UTC if no offset is given. Required unless paging with pageToken. | +| `maxResults` | number | No | Maximum number of events to return per page \(default: 10, max: 100\) | +| `orderBy` | string | No | Order of events \(default: start/dateTime\) | +| `pageToken` | string | No | Full @odata.nextLink URL from a previous page to continue paging | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | array | Array of calendar event objects | +| ↳ `id` | string | Unique event identifier | +| ↳ `subject` | string | Event subject/title | +| ↳ `bodyPreview` | string | Preview of the event body | +| ↳ `start` | object | Event start | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `end` | object | Event end | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `isAllDay` | boolean | Whether the event lasts the entire day | +| ↳ `location` | string | Event location display name | +| ↳ `organizer` | object | Event organizer | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `attendees` | array | Event attendees | +| ↳ `name` | string | Attendee display name | +| ↳ `address` | string | Attendee email address | +| ↳ `type` | string | Attendee type \(required, optional, or resource\) | +| ↳ `response` | string | Attendee response status \(none, accepted, declined, tentativelyAccepted, ...\) | +| ↳ `onlineMeeting` | object | Online-meeting join details, if any | +| ↳ `joinUrl` | string | URL to join the online meeting | +| ↳ `webLink` | string | URL that opens the event in Outlook on the web | +| `nextLink` | string | URL for the next page of results, if any | + +### `outlook_calendar_get_event` + +Get a single Outlook calendar event by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `eventId` | string | Yes | The ID of the calendar event to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | object | The calendar event object | +| ↳ `id` | string | Unique event identifier | +| ↳ `subject` | string | Event subject/title | +| ↳ `bodyPreview` | string | Preview of the event body | +| ↳ `start` | object | Event start | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `end` | object | Event end | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `isAllDay` | boolean | Whether the event lasts the entire day | +| ↳ `location` | string | Event location display name | +| ↳ `organizer` | object | Event organizer | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `attendees` | array | Event attendees | +| ↳ `name` | string | Attendee display name | +| ↳ `address` | string | Attendee email address | +| ↳ `type` | string | Attendee type \(required, optional, or resource\) | +| ↳ `response` | string | Attendee response status \(none, accepted, declined, tentativelyAccepted, ...\) | +| ↳ `onlineMeeting` | object | Online-meeting join details, if any | +| ↳ `joinUrl` | string | URL to join the online meeting | +| ↳ `webLink` | string | URL that opens the event in Outlook on the web | + +### `outlook_calendar_create_event` + +Create a new Outlook calendar event + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | ID of the calendar to create the event in. Defaults to the mailbox default calendar. Calendars shared by another user are not supported and may return 403. | +| `subject` | string | Yes | Event subject/title | +| `startDateTime` | string | Yes | Start time \(ISO 8601, e.g. 2025-06-03T10:00:00-08:00\). A date-only value \(2025-06-03\) for both start and end creates an all-day event. | +| `endDateTime` | string | Yes | End time \(ISO 8601, e.g. 2025-06-03T11:00:00-08:00\). A date-only value \(2025-06-04\) for both start and end creates an all-day event. | +| `timeZone` | string | No | IANA or Windows time zone name \(e.g. America/Los_Angeles\). Used for datetimes without a UTC offset. Defaults to UTC. | +| `body` | string | No | Event body content | +| `contentType` | string | No | Content type for the event body \(text or html\) | +| `location` | string | No | Event location display name | +| `attendees` | string | No | Attendee email addresses \(comma-separated\) | +| `isAllDay` | boolean | No | Whether the event lasts the entire day | +| `isOnlineMeeting` | boolean | No | Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows \(Teams on work/school accounts\); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | object | The created calendar event object | +| ↳ `id` | string | Unique event identifier | +| ↳ `subject` | string | Event subject/title | +| ↳ `bodyPreview` | string | Preview of the event body | +| ↳ `start` | object | Event start | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `end` | object | Event end | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `isAllDay` | boolean | Whether the event lasts the entire day | +| ↳ `location` | string | Event location display name | +| ↳ `organizer` | object | Event organizer | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `attendees` | array | Event attendees | +| ↳ `name` | string | Attendee display name | +| ↳ `address` | string | Attendee email address | +| ↳ `type` | string | Attendee type \(required, optional, or resource\) | +| ↳ `response` | string | Attendee response status \(none, accepted, declined, tentativelyAccepted, ...\) | +| ↳ `onlineMeeting` | object | Online-meeting join details, if any | +| ↳ `joinUrl` | string | URL to join the online meeting | +| ↳ `webLink` | string | URL that opens the event in Outlook on the web | + +### `outlook_calendar_update_event` + +Update an existing Outlook calendar event + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `eventId` | string | Yes | The ID of the calendar event to update | +| `subject` | string | No | New event subject/title | +| `startDateTime` | string | No | New start time \(ISO 8601\). A date-only value \(2025-06-03\) converts the event to all-day. | +| `endDateTime` | string | No | New end time \(ISO 8601\). A date-only value \(2025-06-04\) converts the event to all-day. | +| `timeZone` | string | No | IANA or Windows time zone name applied to updated datetimes without a UTC offset. Defaults to UTC. | +| `body` | string | No | New event body content | +| `contentType` | string | No | Content type for the event body \(text or html\) | +| `location` | string | No | New event location display name | +| `attendees` | string | No | Replacement attendee email addresses \(comma-separated\) | +| `isAllDay` | boolean | No | Whether the event lasts the entire day. Setting this true requires also sending startDateTime, since Graph needs midnight bounds. | +| `isOnlineMeeting` | boolean | No | Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows \(Teams on work/school accounts\); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | object | The updated calendar event object | +| ↳ `id` | string | Unique event identifier | +| ↳ `subject` | string | Event subject/title | +| ↳ `bodyPreview` | string | Preview of the event body | +| ↳ `start` | object | Event start | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `end` | object | Event end | +| ↳ `dateTime` | string | Local date and time \(ISO 8601, no offset\) | +| ↳ `timeZone` | string | IANA or Windows time zone name | +| ↳ `isAllDay` | boolean | Whether the event lasts the entire day | +| ↳ `location` | string | Event location display name | +| ↳ `organizer` | object | Event organizer | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `attendees` | array | Event attendees | +| ↳ `name` | string | Attendee display name | +| ↳ `address` | string | Attendee email address | +| ↳ `type` | string | Attendee type \(required, optional, or resource\) | +| ↳ `response` | string | Attendee response status \(none, accepted, declined, tentativelyAccepted, ...\) | +| ↳ `onlineMeeting` | object | Online-meeting join details, if any | +| ↳ `joinUrl` | string | URL to join the online meeting | +| ↳ `webLink` | string | URL that opens the event in Outlook on the web | + +### `outlook_calendar_delete_event` + +Delete an Outlook calendar event by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `eventId` | string | Yes | The ID of the calendar event to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | object | Delete result details | +| ↳ `eventId` | string | ID of the deleted event | +| ↳ `status` | string | Deletion status | + +### `outlook_calendar_respond` + +Accept, tentatively accept, or decline an Outlook calendar event invitation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `eventId` | string | Yes | The ID of the calendar event to respond to | +| `responseType` | string | Yes | The response: accept, tentativelyAccept, or decline | +| `comment` | string | No | Optional comment to include with the response | +| `sendResponse` | boolean | No | Whether to send a response to the organizer \(default: true\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | object | Response result details | +| ↳ `eventId` | string | ID of the event responded to | +| ↳ `responseType` | string | The response that was sent | +| ↳ `status` | string | Response status | +| ↳ `httpStatus` | number | HTTP status code returned by the API | +| ↳ `requestId` | string | Microsoft Graph request-id header for tracing | + ## Triggers diff --git a/apps/sim/app/api/tools/outlook/calendars/route.ts b/apps/sim/app/api/tools/outlook/calendars/route.ts new file mode 100644 index 00000000000..2c6a68c70b1 --- /dev/null +++ b/apps/sim/app/api/tools/outlook/calendars/route.ts @@ -0,0 +1,163 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { outlookCalendarsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OutlookCalendarsAPI') + +/** + * Microsoft Graph paginates `calendars` via the `@odata.nextLink` absolute URL in the + * response body. Bound the drain so a pathological account can't loop unbounded. + * @see https://learn.microsoft.com/en-us/graph/api/user-list-calendars + */ +const OUTLOOK_CALENDARS_PAGE_SIZE = 100 +const MAX_OUTLOOK_CALENDARS_PAGES = 10 + +interface OutlookCalendar { + id: string + name: string + canEdit?: boolean + owner?: { name?: string; address?: string } +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const parsed = await parseRequest(outlookCalendarsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId } = parsed.data.query + + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId') + if (!credentialIdValidation.isValid) { + logger.warn('Invalid credentialId format', { error: credentialIdValidation.error }) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + + try { + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn('Credential access denied', { error: credAccess.error }) + return NextResponse.json( + { error: credAccess.error || 'Authentication required' }, + { status: 401 } + ) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + credAccess.credentialOwnerUserId, + generateRequestId() + ) + + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId, + userId: credAccess.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const calendars: OutlookCalendar[] = [] + let nextUrl: string | undefined = + `https://graph.microsoft.com/v1.0/me/calendars?$top=${OUTLOOK_CALENDARS_PAGE_SIZE}` + + for (let page = 0; page < MAX_OUTLOOK_CALENDARS_PAGES && nextUrl; page++) { + const response = await fetch(nextUrl, { + 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 calendars', { + status: response.status, + error: errorData, + endpoint: nextUrl, + }) + + 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() + calendars.push(...((data.value as OutlookCalendar[]) || [])) + + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + + if (nextUrl && page === MAX_OUTLOOK_CALENDARS_PAGES - 1) { + logger.warn('Outlook calendars hit pagination cap; calendar list may be incomplete', { + pages: MAX_OUTLOOK_CALENDARS_PAGES, + collected: calendars.length, + }) + } + } + + return NextResponse.json({ + calendars: calendars.map((calendar) => ({ + id: calendar.id, + name: calendar.name, + type: 'calendar', + canEdit: calendar.canEdit ?? false, + ownerAddress: calendar.owner?.address ?? null, + })), + }) + } catch (innerError) { + logger.error('Error during API requests:', innerError) + + const errorMessage = toError(innerError).message + 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 calendars request:', error) + return NextResponse.json( + { + error: 'Failed to retrieve Outlook calendars', + details: toError(error).message, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/outlook.test.ts b/apps/sim/blocks/blocks/outlook.test.ts new file mode 100644 index 00000000000..cd60240b048 --- /dev/null +++ b/apps/sim/blocks/blocks/outlook.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { tools as toolRegistry } from '@/tools/registry' +import { OutlookBlock } from './outlook' + +const block = OutlookBlock + +/** Every calendar operation exposed by the block's operation dropdown. */ +const CALENDAR_OPERATIONS = [ + 'list_events_calendar', + 'get_event_calendar', + 'create_event_calendar', + 'update_event_calendar', + 'delete_event_calendar', + 'respond_calendar', +] as const + +/** Representative values for every calendar subBlock, as the editor would supply them. */ +const CALENDAR_SUBBLOCK_VALUES: Record = { + oauthCredential: 'cred-1', + calendarId: 'cal-1', + calEventId: 'evt-1', + calWindowStart: '2025-06-03T00:00:00Z', + calWindowEnd: '2025-06-10T00:00:00Z', + calMaxResults: '25', + calOrderBy: 'start/dateTime', + calPageToken: '', + calSubject: 'Sync', + calStartDateTime: '2025-06-03T10:00:00Z', + calEndDateTime: '2025-06-03T11:00:00Z', + calBody: 'agenda', + calContentType: 'text', + calLocation: 'Room 1', + calAttendees: 'a@x.com', + calTimeZone: 'America/Los_Angeles', + calIsAllDay: false, + calIsOnlineMeeting: true, + calResponseType: 'accept', + calComment: 'see you', + calSendResponse: 'true', +} + +describe('OutlookBlock calendar operations', () => { + it.each(CALENDAR_OPERATIONS)('%s resolves to a registered tool in tools.access', (operation) => { + const toolId = block.tools.config.tool?.({ operation }) as string + expect(toolRegistry[toolId]).toBeDefined() + expect(block.tools.access).toContain(toolId) + }) + + it.each(CALENDAR_OPERATIONS)('%s supplies every required tool param', (operation) => { + const toolId = block.tools.config.tool?.({ operation }) as string + const mapped = block.tools.config.params?.({ operation, ...CALENDAR_SUBBLOCK_VALUES }) ?? {} + const required = Object.entries(toolRegistry[toolId].params ?? {}) + .filter(([id, config]) => config.required && id !== 'accessToken') + .map(([id]) => id) + + const missing = required.filter((id) => mapped[id] === undefined || mapped[id] === '') + expect(missing).toEqual([]) + }) + + it.each(CALENDAR_OPERATIONS)('%s emits no params the tool cannot accept', (operation) => { + const toolId = block.tools.config.tool?.({ operation }) as string + const mapped = block.tools.config.params?.({ operation, ...CALENDAR_SUBBLOCK_VALUES }) ?? {} + // `operation` and the credential ride along for the executor, not the tool contract. + const accepted = new Set([ + ...Object.keys(toolRegistry[toolId].params ?? {}), + 'operation', + 'oauthCredential', + ]) + + const orphans = Object.keys(mapped).filter( + (key) => !accepted.has(key) && mapped[key] !== undefined + ) + expect(orphans).toEqual([]) + }) + + it('maps calendar operations onto calendar tools one-to-one', () => { + const reachable = CALENDAR_OPERATIONS.map( + (operation) => block.tools.config.tool?.({ operation }) as string + ) + const declared = block.tools.access.filter((id) => id.startsWith('outlook_calendar_')) + // No calendar tool is declared but unreachable, and none is reached twice. + expect([...reachable].sort()).toEqual([...declared].sort()) + expect(new Set(reachable).size).toBe(reachable.length) + }) + + it('routes the calendar picker through the calendarId canonical param', () => { + const members = block.subBlocks.filter((s) => s.canonicalParamId === 'calendarId') + expect(members.map((s) => s.id).sort()).toEqual(['calendarSelector', 'manualCalendarId']) + // Exactly one basic-mode member, per the canonical-group contract. + expect(members.filter((s) => s.mode === 'basic')).toHaveLength(1) + // canonicalParamId must never collide with a subBlock id. + expect(block.subBlocks.some((s) => s.id === 'calendarId')).toBe(false) + // A blank pick means "default calendar", so it must not be forwarded. + const mapped = block.tools.config.params?.({ + operation: 'create_event_calendar', + ...CALENDAR_SUBBLOCK_VALUES, + calendarId: ' ', + }) + expect(mapped?.calendarId).toBeUndefined() + }) + + it('always sends sendResponse so an unset dropdown cannot read as "do not notify"', () => { + const withoutChoice = block.tools.config.params?.({ + operation: 'respond_calendar', + ...CALENDAR_SUBBLOCK_VALUES, + calSendResponse: undefined, + }) + expect(withoutChoice?.sendResponse).toBe(true) + + const declined = block.tools.config.params?.({ + operation: 'respond_calendar', + ...CALENDAR_SUBBLOCK_VALUES, + calSendResponse: 'false', + }) + expect(declined?.sendResponse).toBe(false) + }) +}) diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 5c75331f9da..e9ec3bb9e66 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -9,10 +9,10 @@ import { getTrigger } from '@/triggers' export const OutlookBlock: BlockConfig = { type: 'outlook', name: 'Outlook', - description: 'Send, read, search, reply, organize, and manage Outlook email', + description: 'Send, read, search, reply, organize, and manage Outlook email and calendar', authMode: AuthMode.OAuth, longDescription: - 'Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can be used in trigger mode to trigger a workflow when a new email is received.', + 'Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can also list, create, update, delete, and respond to calendar events. Can be used in trigger mode to trigger a workflow when a new email is received.', docsLink: 'https://docs.sim.ai/integrations/outlook', category: 'tools', integrationType: IntegrationType.Email, @@ -42,6 +42,12 @@ export const OutlookBlock: BlockConfig = { { label: 'Create Folder', id: 'create_folder_outlook' }, { label: 'List Attachments', id: 'list_attachments_outlook' }, { label: 'Get Attachment', id: 'get_attachment_outlook' }, + { label: 'List Calendar Events', id: 'list_events_calendar' }, + { label: 'Get Calendar Event', id: 'get_event_calendar' }, + { label: 'Create Event', id: 'create_event_calendar' }, + { label: 'Update Event', id: 'update_event_calendar' }, + { label: 'Delete Event', id: 'delete_event_calendar' }, + { label: 'Respond to Invite', id: 'respond_calendar' }, ], value: () => 'send_outlook', }, @@ -388,6 +394,350 @@ export const OutlookBlock: BlockConfig = { mode: 'advanced', required: false, }, + // Calendar - Calendar picker (basic). Only list/create are calendar-scoped: event IDs are + // unique per mailbox, so get/update/delete/respond address /me/events/{id} directly. + { + id: 'calendarSelector', + title: 'Calendar', + type: 'file-selector', + canonicalParamId: 'calendarId', + serviceId: 'outlook', + selectorKey: 'outlook.calendars', + requiredScopes: getScopesForService('outlook'), + placeholder: 'Select calendar (defaults to your default calendar)', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['list_events_calendar', 'create_event_calendar'], + }, + }, + // Calendar - Manual calendar ID (advanced) + { + id: 'manualCalendarId', + title: 'Calendar', + type: 'short-input', + canonicalParamId: 'calendarId', + placeholder: 'Enter calendar ID (leave blank for the default calendar)', + dependsOn: ['credential'], + mode: 'advanced', + condition: { + field: 'operation', + value: ['list_events_calendar', 'create_event_calendar'], + }, + }, + // Calendar - Event ID (get / update / delete / respond) + { + id: 'calEventId', + title: 'Event ID', + type: 'short-input', + placeholder: 'ID of the calendar event', + condition: { + field: 'operation', + value: [ + 'get_event_calendar', + 'update_event_calendar', + 'delete_event_calendar', + 'respond_calendar', + ], + }, + required: true, + }, + // Calendar - Window start/end (list events). Required: calendarView needs both bounds. + { + id: 'calWindowStart', + title: 'Start of Window', + type: 'short-input', + placeholder: '2025-06-03T00:00:00-08:00', + condition: { field: 'operation', value: 'list_events_calendar' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp with timezone offset for the START of a calendar time window, based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS+HH:MM or YYYY-MM-DDTHH:MM:SS-HH:MM +Examples: +- "this week" -> Monday of the current week at 00:00:00 with local timezone offset +- "today" -> today's date at 00:00:00 with local timezone offset +- "the next 30 days" -> the current date and time with local timezone offset + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the window start (e.g., "this week", "today")...', + generationType: 'timestamp', + }, + }, + { + id: 'calWindowEnd', + title: 'End of Window', + type: 'short-input', + placeholder: '2025-06-10T00:00:00-08:00', + condition: { field: 'operation', value: 'list_events_calendar' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp with timezone offset for the END of a calendar time window, based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS+HH:MM or YYYY-MM-DDTHH:MM:SS-HH:MM +Examples: +- "this week" -> the Monday after the current week at 00:00:00 with local timezone offset +- "today" -> tomorrow's date at 00:00:00 with local timezone offset +- "the next 30 days" -> the current date plus 30 days with local timezone offset + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the window end (e.g., "end of this week", "in 30 days")...', + generationType: 'timestamp', + }, + }, + // Calendar - List events options + { + id: 'calMaxResults', + title: 'Number of Results', + type: 'short-input', + placeholder: 'Number of events to retrieve (default: 10, max: 100)', + condition: { field: 'operation', value: 'list_events_calendar' }, + }, + { + id: 'calOrderBy', + title: 'Order By', + type: 'short-input', + placeholder: 'start/dateTime', + condition: { field: 'operation', value: 'list_events_calendar' }, + mode: 'advanced', + }, + { + id: 'calPageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'nextLink from a previous response (window fields are then ignored)', + condition: { field: 'operation', value: 'list_events_calendar' }, + mode: 'advanced', + }, + // Calendar - Create event (required start/end) + { + id: 'calSubject', + title: 'Subject', + type: 'short-input', + placeholder: 'Event title', + condition: { field: 'operation', value: 'create_event_calendar' }, + required: true, + }, + { + id: 'calStartDateTime', + title: 'Start Date & Time', + type: 'short-input', + placeholder: '2025-06-03T10:00:00-08:00', + condition: { field: 'operation', value: 'create_event_calendar' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp with timezone offset based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS+HH:MM or YYYY-MM-DDTHH:MM:SS-HH:MM +Examples: +- "tomorrow at 2pm" -> Calculate tomorrow's date at 14:00:00 with local timezone offset +- "next Monday at 9am" -> Calculate next Monday at 09:00:00 with local timezone offset +- "in 2 hours" -> Calculate current time + 2 hours with local timezone offset + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the start time (e.g., "tomorrow at 2pm")...', + generationType: 'timestamp', + }, + }, + { + id: 'calEndDateTime', + title: 'End Date & Time', + type: 'short-input', + placeholder: '2025-06-03T11:00:00-08:00', + condition: { field: 'operation', value: 'create_event_calendar' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp with timezone offset based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS+HH:MM or YYYY-MM-DDTHH:MM:SS-HH:MM +Examples: +- "tomorrow at 3pm" -> Calculate tomorrow's date at 15:00:00 with local timezone offset +- "an hour after the start" -> Calculate the start time + 1 hour with local timezone offset + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the end time (e.g., "an hour later")...', + generationType: 'timestamp', + }, + }, + // Calendar - Update event (optional start/end/subject) + { + id: 'calSubject', + title: 'New Subject', + type: 'short-input', + placeholder: 'Updated event title', + condition: { field: 'operation', value: 'update_event_calendar' }, + required: false, + }, + { + id: 'calStartDateTime', + title: 'New Start Date & Time', + type: 'short-input', + placeholder: '2025-06-03T10:00:00-08:00', + condition: { field: 'operation', value: 'update_event_calendar' }, + required: false, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp with timezone offset based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS+HH:MM or YYYY-MM-DDTHH:MM:SS-HH:MM +Examples: +- "tomorrow at 2pm" -> Calculate tomorrow's date at 14:00:00 with local timezone offset +- "push it back an hour" -> Calculate the existing start + 1 hour with local timezone offset + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the new start time...', + generationType: 'timestamp', + }, + }, + { + id: 'calEndDateTime', + title: 'New End Date & Time', + type: 'short-input', + placeholder: '2025-06-03T11:00:00-08:00', + condition: { field: 'operation', value: 'update_event_calendar' }, + required: false, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp with timezone offset based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS+HH:MM or YYYY-MM-DDTHH:MM:SS-HH:MM +Examples: +- "tomorrow at 3pm" -> Calculate tomorrow's date at 15:00:00 with local timezone offset +- "extend it by 30 minutes" -> Calculate the existing end + 30 minutes with local timezone offset + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the new end time...', + generationType: 'timestamp', + }, + }, + // Calendar - Shared create/update fields + { + id: 'calBody', + title: 'Body', + type: 'long-input', + placeholder: 'Event description', + condition: { + field: 'operation', + value: ['create_event_calendar', 'update_event_calendar'], + }, + required: false, + }, + { + id: 'calContentType', + title: 'Body Content Type', + type: 'dropdown', + options: [ + { label: 'Plain Text', id: 'text' }, + { label: 'HTML', id: 'html' }, + ], + condition: { + field: 'operation', + value: ['create_event_calendar', 'update_event_calendar'], + }, + value: () => 'text', + mode: 'advanced', + required: false, + }, + { + id: 'calLocation', + title: 'Location', + type: 'short-input', + placeholder: 'Event location', + condition: { + field: 'operation', + value: ['create_event_calendar', 'update_event_calendar'], + }, + required: false, + }, + // Calendar - Attendees (create / update) + { + id: 'calAttendees', + title: 'Attendees', + type: 'short-input', + placeholder: 'Attendee emails (comma-separated)', + condition: { + field: 'operation', + value: ['create_event_calendar', 'update_event_calendar'], + }, + required: false, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of attendee email addresses based on the user's description. +Use only valid email addresses, separated by ", " with no trailing comma. +Example: john@example.com, jane@example.com + +Return ONLY the comma-separated email list - no explanations, no extra text.`, + placeholder: 'Describe who should attend...', + }, + }, + { + id: 'calTimeZone', + title: 'Time Zone', + type: 'short-input', + placeholder: 'America/Los_Angeles', + condition: { + field: 'operation', + value: ['create_event_calendar', 'update_event_calendar'], + }, + mode: 'advanced', + required: false, + }, + { + id: 'calIsAllDay', + title: 'All Day', + type: 'switch', + condition: { + field: 'operation', + value: ['create_event_calendar', 'update_event_calendar'], + }, + mode: 'advanced', + }, + { + id: 'calIsOnlineMeeting', + title: 'Add Online Meeting', + type: 'switch', + condition: { + field: 'operation', + value: ['create_event_calendar', 'update_event_calendar'], + }, + mode: 'advanced', + }, + // Calendar - Respond to invite + { + id: 'calResponseType', + title: 'Response', + type: 'dropdown', + options: [ + { label: 'Accept', id: 'accept' }, + { label: 'Tentative', id: 'tentativelyAccept' }, + { label: 'Decline', id: 'decline' }, + ], + condition: { field: 'operation', value: 'respond_calendar' }, + value: () => 'accept', + required: true, + }, + { + id: 'calComment', + title: 'Comment', + type: 'long-input', + placeholder: 'Optional message to the organizer', + condition: { field: 'operation', value: 'respond_calendar' }, + required: false, + }, + // A switch would render OFF while Graph's default is to notify, so this is a dropdown + // whose visible default matches the behavior. + { + id: 'calSendResponse', + title: 'Send Response to Organizer', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + condition: { field: 'operation', value: 'respond_calendar' }, + value: () => 'true', + mode: 'advanced', + required: false, + }, ...getTrigger('outlook_poller').subBlocks, ], tools: { @@ -409,6 +759,12 @@ export const OutlookBlock: BlockConfig = { 'outlook_create_folder', 'outlook_list_attachments', 'outlook_get_attachment', + 'outlook_calendar_list_events', + 'outlook_calendar_get_event', + 'outlook_calendar_create_event', + 'outlook_calendar_update_event', + 'outlook_calendar_delete_event', + 'outlook_calendar_respond', ], config: { tool: (params) => { @@ -447,6 +803,18 @@ export const OutlookBlock: BlockConfig = { return 'outlook_list_attachments' case 'get_attachment_outlook': return 'outlook_get_attachment' + case 'list_events_calendar': + return 'outlook_calendar_list_events' + case 'get_event_calendar': + return 'outlook_calendar_get_event' + case 'create_event_calendar': + return 'outlook_calendar_create_event' + case 'update_event_calendar': + return 'outlook_calendar_update_event' + case 'delete_event_calendar': + return 'outlook_calendar_delete_event' + case 'respond_calendar': + return 'outlook_calendar_respond' default: throw new Error(`Invalid Outlook operation: ${params.operation}`) } @@ -467,6 +835,26 @@ export const OutlookBlock: BlockConfig = { includeHiddenFolders, categories, maxResults, + calendarId, + calEventId, + calWindowStart, + calWindowEnd, + calMaxResults, + calOrderBy, + calPageToken, + calSubject, + calStartDateTime, + calEndDateTime, + calBody, + calContentType, + calLocation, + calAttendees, + calTimeZone, + calIsAllDay, + calIsOnlineMeeting, + calResponseType, + calComment, + calSendResponse, ...rest } = params @@ -555,6 +943,70 @@ export const OutlookBlock: BlockConfig = { } } + const isSet = (value: unknown): boolean => + value !== undefined && value !== null && value !== '' + + // calendarId is already the canonical param. Blank means the default calendar, so + // only forward it when the user actually picked one. + if (['list_events_calendar', 'create_event_calendar'].includes(rest.operation)) { + const effectiveCalendarId = calendarId ? String(calendarId).trim() : '' + if (effectiveCalendarId) { + rest.calendarId = effectiveCalendarId + } + } + + if (rest.operation === 'list_events_calendar') { + if (calWindowStart) rest.startDateTime = String(calWindowStart).trim() + if (calWindowEnd) rest.endDateTime = String(calWindowEnd).trim() + if (isSet(calMaxResults)) rest.maxResults = Number(calMaxResults) + if (calOrderBy) rest.orderBy = String(calOrderBy).trim() + if (calPageToken) rest.pageToken = String(calPageToken).trim() + } + + if ( + [ + 'get_event_calendar', + 'update_event_calendar', + 'delete_event_calendar', + 'respond_calendar', + ].includes(rest.operation) + ) { + if (calEventId) rest.eventId = String(calEventId).trim() + } + + if (rest.operation === 'create_event_calendar') { + if (calSubject) rest.subject = calSubject + if (calStartDateTime) rest.startDateTime = String(calStartDateTime).trim() + if (calEndDateTime) rest.endDateTime = String(calEndDateTime).trim() + if (isSet(calBody)) rest.body = calBody + if (calContentType) rest.contentType = calContentType + if (isSet(calLocation)) rest.location = calLocation + if (isSet(calAttendees)) rest.attendees = calAttendees + if (calTimeZone) rest.timeZone = String(calTimeZone).trim() + rest.isAllDay = toBool(calIsAllDay) + rest.isOnlineMeeting = toBool(calIsOnlineMeeting) + } + + if (rest.operation === 'update_event_calendar') { + if (isSet(calSubject)) rest.subject = calSubject + if (calStartDateTime) rest.startDateTime = String(calStartDateTime).trim() + if (calEndDateTime) rest.endDateTime = String(calEndDateTime).trim() + if (isSet(calBody)) rest.body = calBody + if (calContentType) rest.contentType = calContentType + if (isSet(calLocation)) rest.location = calLocation + if (isSet(calAttendees)) rest.attendees = calAttendees + if (calTimeZone) rest.timeZone = String(calTimeZone).trim() + if (isSet(calIsAllDay)) rest.isAllDay = toBool(calIsAllDay) + if (isSet(calIsOnlineMeeting)) rest.isOnlineMeeting = toBool(calIsOnlineMeeting) + } + + if (rest.operation === 'respond_calendar') { + if (calResponseType) rest.responseType = calResponseType + if (isSet(calComment)) rest.comment = calComment + // Notifying the organizer is the default; an unset dropdown must not read as "no". + rest.sendResponse = isSet(calSendResponse) ? toBool(calSendResponse) : true + } + return { ...rest, oauthCredential, @@ -600,11 +1052,51 @@ export const OutlookBlock: BlockConfig = { categories: { type: 'string', description: 'Comma-separated category names' }, flagStatus: { type: 'string', description: 'Follow-up flag status' }, importance: { type: 'string', description: 'Message importance level' }, + // Calendar operation inputs + calendarId: { + type: 'string', + description: 'Calendar to read from or write to (canonical param); blank = default calendar', + }, + calEventId: { type: 'string', description: 'Calendar event ID' }, + calWindowStart: { type: 'string', description: 'Start of the calendar time window (ISO 8601)' }, + calWindowEnd: { type: 'string', description: 'End of the calendar time window (ISO 8601)' }, + calMaxResults: { type: 'number', description: 'Maximum number of calendar events to return' }, + calOrderBy: { type: 'string', description: 'Order of calendar events' }, + calPageToken: { type: 'string', description: 'nextLink URL for paging calendar events' }, + calSubject: { type: 'string', description: 'Calendar event subject/title' }, + calStartDateTime: { type: 'string', description: 'Calendar event start (ISO 8601)' }, + calEndDateTime: { type: 'string', description: 'Calendar event end (ISO 8601)' }, + calBody: { type: 'string', description: 'Calendar event body content' }, + calContentType: { + type: 'string', + description: 'Calendar event body content type (text or html)', + }, + calLocation: { type: 'string', description: 'Calendar event location' }, + calAttendees: { type: 'string', description: 'Attendee emails (comma-separated)' }, + calTimeZone: { type: 'string', description: 'IANA/Windows time zone name' }, + calIsAllDay: { type: 'boolean', description: 'Whether the event lasts the entire day' }, + calIsOnlineMeeting: { + type: 'boolean', + description: 'Attach an online meeting (Teams; work/school accounts only)', + }, + calResponseType: { + type: 'string', + description: 'Invite response (accept, tentativelyAccept, or decline)', + }, + calComment: { type: 'string', description: 'Comment to send with an invite response' }, + calSendResponse: { + type: 'string', + description: 'Whether to notify the organizer ("true" or "false"; defaults to "true")', + }, }, outputs: { // Common outputs message: { type: 'string', description: 'Response message' }, - results: { type: 'json', description: 'Operation results' }, + results: { + type: 'json', + description: + 'Operation results. Calendar operations return the event(s): {id, subject, start, end, isAllDay, location, organizer, attendees, onlineMeeting, webLink, bodyPreview}', + }, // Send operation specific outputs status: { type: 'string', description: 'Email send status (sent)' }, timestamp: { type: 'string', description: 'Operation timestamp' }, @@ -635,6 +1127,8 @@ export const OutlookBlock: BlockConfig = { // Update message operation outputs categories: { type: 'json', description: 'Categories assigned to the message' }, flagStatus: { type: 'string', description: 'Follow-up flag status of the message' }, + // Calendar operation outputs + nextLink: { type: 'string', description: 'URL for the next page of calendar events, if any' }, // Trigger outputs email: { type: 'json', description: 'Email data from trigger' }, rawEmail: { type: 'json', description: 'Complete raw email data from Microsoft Graph API' }, @@ -725,6 +1219,34 @@ export const OutlookBlockMeta = { category: 'operations', tags: ['legal', 'analysis', 'automation'], }, + { + icon: OutlookIcon, + title: 'Outlook meeting scheduler', + prompt: + 'Build a workflow that reads emails requesting a meeting, checks my Outlook calendar for the requested window, creates an Outlook calendar event with the sender as an attendee and a Teams link, and replies from Outlook confirming the time.', + modules: ['agent', 'workflows'], + category: 'productivity', + tags: ['individual', 'communication', 'automation'], + }, + { + icon: OutlookIcon, + title: 'Outlook daily agenda digest', + prompt: + 'Create a scheduled workflow that lists my Outlook calendar events for the day each morning, summarizes each meeting with its attendees and join link, and posts the agenda to a Slack DM before my first meeting.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'productivity', + tags: ['individual', 'reporting', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: OutlookIcon, + title: 'Outlook invite auto-responder', + prompt: + 'Build a workflow that reviews new Outlook meeting invitations, checks my calendar for conflicts in that time window, and accepts, tentatively accepts, or declines each invite with a short note to the organizer explaining the decision.', + modules: ['agent', 'workflows'], + category: 'productivity', + tags: ['individual', 'communication', 'automation'], + }, ], skills: [ { @@ -751,5 +1273,29 @@ export const OutlookBlockMeta = { content: '# File Email to Folder\n\nOrganize the inbox by moving a message into the right folder.\n\n## Steps\n1. Identify the email and the destination folder.\n2. Run Move Email to relocate the message.\n3. Optionally run Mark as Read so it does not linger as unread.\n\n## Output\nConfirm the email moved, naming the source and destination folders.', }, + { + name: 'schedule-meeting', + description: 'Create an Outlook calendar event and invite the right attendees.', + content: + '# Schedule Meeting\n\nPut a meeting on the calendar with the right people and context.\n\n## Steps\n1. Gather the title, start and end times, attendees, and any agenda notes.\n2. Run List Calendar Events over the proposed window to confirm the slot is free; pick another time if it is not.\n3. Run Create Event with the subject, start, end, and attendee emails. Turn on Add Online Meeting when the attendees are remote (Teams; work or school accounts only).\n\n## Output\nConfirm the event was created, naming the title, time, attendees, and the join link if one was added.', + }, + { + name: 'summarize-agenda', + description: 'List Outlook calendar events for a time window and summarize the day.', + content: + '# Summarize Agenda\n\nTurn a calendar window into a short, readable agenda.\n\n## Steps\n1. Determine the window (today, this week) as ISO 8601 start and end timestamps.\n2. Run List Calendar Events for that window. Follow the returned nextLink if more pages are needed.\n3. For each event, note the time, subject, organizer, attendees, and join link.\n\n## Output\nA chronological agenda for the window, one line per meeting, calling out back-to-back blocks and any event with no agenda in its body.', + }, + { + name: 'respond-to-invite', + description: 'Accept, tentatively accept, or decline an Outlook meeting invitation.', + content: + '# Respond to Invite\n\nDecide on a meeting invitation and reply to the organizer.\n\n## Steps\n1. Run Get Calendar Event on the invite to read its time, organizer, and attendees.\n2. Run List Calendar Events over the same window to check for conflicts.\n3. Run Respond to Invite with accept, tentativelyAccept, or decline, adding a short comment when declining or proposing another time.\n\n## Output\nState the response that was sent, the reason, and any conflicting meeting that drove the decision.', + }, + { + name: 'reschedule-event', + description: 'Move an existing Outlook calendar event to a new time.', + content: + '# Reschedule Event\n\nShift a meeting and keep the attendees informed.\n\n## Steps\n1. Run Get Calendar Event to read the current time, attendees, and body.\n2. Run List Calendar Events over the proposed new window to confirm it is free.\n3. Run Update Event with the new start and end. Send both bounds together so the window stays valid.\n4. Optionally send the attendees a note explaining the change.\n\n## Output\nConfirm the event moved, stating the old time, the new time, and who was notified.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/hooks/selectors/providers/microsoft/selectors.ts b/apps/sim/hooks/selectors/providers/microsoft/selectors.ts index f18a854cc4a..70ead52d08f 100644 --- a/apps/sim/hooks/selectors/providers/microsoft/selectors.ts +++ b/apps/sim/hooks/selectors/providers/microsoft/selectors.ts @@ -56,6 +56,28 @@ export const microsoftSelectors = { })) }, }, + 'outlook.calendars': { + key: 'outlook.calendars', + contracts: [selectorContracts.outlookCalendarsSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'outlook.calendars', + context.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'outlook.calendars') + const data = await requestJson(selectorContracts.outlookCalendarsSelectorContract, { + query: { credentialId }, + signal, + }) + return (data.calendars || []).map((calendar) => ({ + id: calendar.id, + label: calendar.name, + })) + }, + }, 'microsoft.teams': { key: 'microsoft.teams', contracts: [selectorContracts.microsoftTeamsSelectorContract], @@ -363,6 +385,7 @@ export const microsoftSelectors = { SelectorKey, | 'microsoft.planner.plans' | 'outlook.folders' + | 'outlook.calendars' | 'microsoft.teams' | 'microsoft.chats' | 'microsoft.channels' diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 8eddda86805..c8267d1369e 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -31,6 +31,7 @@ export type SelectorKey = | 'slack.users' | 'gmail.labels' | 'outlook.folders' + | 'outlook.calendars' | 'google.calendar' | 'jira.issues' | 'jira.projects' diff --git a/apps/sim/lib/api/contracts/selectors/index.ts b/apps/sim/lib/api/contracts/selectors/index.ts index 7c4ac0d5005..3cfd1d15116 100644 --- a/apps/sim/lib/api/contracts/selectors/index.ts +++ b/apps/sim/lib/api/contracts/selectors/index.ts @@ -73,6 +73,7 @@ import { onedriveFilesSelectorContract, onedriveFolderSelectorContract, onedriveFoldersSelectorContract, + outlookCalendarsSelectorContract, outlookFoldersSelectorContract, } from '@/lib/api/contracts/selectors/microsoft' import { @@ -172,6 +173,7 @@ export const selectorContractsByPath = { '/api/tools/hubspot/pipelines': hubspotPipelinesSelectorContract, '/api/tools/hubspot/owners': hubspotOwnersSelectorContract, '/api/tools/outlook/folders': outlookFoldersSelectorContract, + '/api/tools/outlook/calendars': outlookCalendarsSelectorContract, '/api/tools/google_calendar/calendars': googleCalendarSelectorContract, '/api/tools/microsoft-teams/teams': microsoftTeamsSelectorContract, '/api/tools/microsoft-teams/chats': microsoftChatsSelectorContract, diff --git a/apps/sim/lib/api/contracts/selectors/microsoft.ts b/apps/sim/lib/api/contracts/selectors/microsoft.ts index 09304896920..40fd939a5ff 100644 --- a/apps/sim/lib/api/contracts/selectors/microsoft.ts +++ b/apps/sim/lib/api/contracts/selectors/microsoft.ts @@ -77,6 +77,14 @@ export const outlookFoldersSelectorContract = defineGetSelector( z.object({ folders: z.array(z.object({ id: z.string(), name: z.string() }).passthrough()) }) ) +export const outlookCalendarsQuerySchema = credentialIdQuerySchema + +export const outlookCalendarsSelectorContract = defineGetSelector( + '/api/tools/outlook/calendars', + outlookCalendarsQuerySchema, + z.object({ calendars: z.array(z.object({ id: z.string(), name: z.string() }).passthrough()) }) +) + export const microsoftTeamsSelectorContract = definePostSelector( '/api/tools/microsoft-teams/teams', credentialWorkflowBodySchema, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index c1790ceb974..8908eefeb49 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-27", + "updatedAt": "2026-07-29", "integrations": [ { "type": "onepassword", @@ -13202,8 +13202,8 @@ "type": "outlook", "slug": "outlook", "name": "Outlook", - "description": "Send, read, search, reply, organize, and manage Outlook email", - "longDescription": "Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can be used in trigger mode to trigger a workflow when a new email is received.", + "description": "Send, read, search, reply, organize, and manage Outlook email and calendar", + "longDescription": "Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can also list, create, update, delete, and respond to calendar events. Can be used in trigger mode to trigger a workflow when a new email is received.", "bgColor": "#FFFFFF", "iconName": "OutlookIcon", "docsUrl": "https://docs.sim.ai/integrations/outlook", @@ -13275,9 +13275,33 @@ { "name": "Get Attachment", "description": "Get a single attachment on an Outlook message, including its file contents" + }, + { + "name": "List Calendar Events", + "description": "List Outlook calendar events within a start/end time window" + }, + { + "name": "Get Calendar Event", + "description": "Get a single Outlook calendar event by ID" + }, + { + "name": "Create Event", + "description": "Create a new Outlook calendar event" + }, + { + "name": "Update Event", + "description": "Update an existing Outlook calendar event" + }, + { + "name": "Delete Event", + "description": "Delete an Outlook calendar event by ID" + }, + { + "name": "Respond to Invite", + "description": "Accept, tentatively accept, or decline an Outlook calendar event invitation" } ], - "operationCount": 17, + "operationCount": 23, "triggers": [ { "id": "outlook_poller", diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 747b607a7f1..fc3c6bc7064 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -392,10 +392,30 @@ export const OAUTH_PROVIDERS: Record = { }, outlook: { name: 'Outlook', - description: 'Connect to Outlook and manage emails.', + description: 'Connect to Outlook and manage emails and calendar events.', providerId: 'outlook', icon: OutlookIcon, baseProviderIcon: MicrosoftIcon, + /** + * `Calendars.ReadWrite` backs the Outlook calendar operations. Graph documents it + * as the sole accepted permission for creating and updating events and for + * accept / tentativelyAccept / decline ("Higher: Not available"), and it is + * supported for both work/school and personal Microsoft accounts. + * + * Do NOT add `Calendars.ReadWrite.Shared` here. This provider is shared by work + * and personal Outlook accounts, and the `.Shared` calendar scopes are not + * confirmed supported for personal Microsoft accounts — requesting one risks + * failing consent for personal users, which would take mail access down with it. + * That is the same reasoning that kept `findMeetingTimes` out of this integration. + * The consequence is that calendar operations target calendars the account owns; + * picking a calendar shared by another user may return 403 from Graph. + * + * Microsoft only grants newly-added scopes on a fresh authorization, so users who + * connected Outlook before `Calendars.ReadWrite` existed must reconnect + * (re-consent) before the calendar operations will work. + * + * @see https://learn.microsoft.com/en-us/graph/permissions-reference + */ scopes: [ 'openid', 'profile', @@ -404,6 +424,7 @@ export const OAUTH_PROVIDERS: Record = { 'Mail.ReadBasic', 'Mail.Read', 'Mail.Send', + 'Calendars.ReadWrite', 'offline_access', ], }, diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index f087b6948f7..acc0945270e 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -335,6 +335,10 @@ describe('getCanonicalScopesForProvider', () => { expect(outlookScopes.length).toBeGreaterThan(0) expect(outlookScopes).toContain('Mail.ReadWrite') + expect(outlookScopes).toContain('Calendars.ReadWrite') + // .Shared is deliberately absent: unconfirmed for personal MSA, and this provider + // serves personal accounts whose mail access would break if consent failed. + expect(outlookScopes).not.toContain('Calendars.ReadWrite.Shared') const excelScopes = getCanonicalScopesForProvider('microsoft-excel') @@ -618,6 +622,8 @@ describe('getScopesForService', () => { expect(scopes.length).toBeGreaterThan(0) expect(scopes).toContain('Mail.ReadWrite') + expect(scopes).toContain('Calendars.ReadWrite') + expect(scopes).not.toContain('Calendars.ReadWrite.Shared') }) it.concurrent('should return empty array for empty string', () => { diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 641355949b0..ce4e754d727 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -237,6 +237,7 @@ export const SCOPE_DESCRIPTIONS: Record = { 'Mail.ReadBasic': 'Read Microsoft emails', 'Mail.Read': 'Read Microsoft emails', 'Mail.Send': 'Send emails', + 'Calendars.ReadWrite': 'Read and manage Outlook calendar events', 'Files.Read': 'Read OneDrive files', 'Files.ReadWrite': 'Read and write OneDrive files', 'Tasks.ReadWrite': 'Read and manage Planner tasks', diff --git a/apps/sim/tools/outlook/calendar-tools.test.ts b/apps/sim/tools/outlook/calendar-tools.test.ts new file mode 100644 index 00000000000..57a00e20f91 --- /dev/null +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -0,0 +1,332 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { outlookCalendarCreateEventTool } from '@/tools/outlook/calendar_create_event' +import { outlookCalendarDeleteEventTool } from '@/tools/outlook/calendar_delete_event' +import { outlookCalendarGetEventTool } from '@/tools/outlook/calendar_get_event' +import { outlookCalendarListEventsTool } from '@/tools/outlook/calendar_list_events' +import { outlookCalendarRespondTool } from '@/tools/outlook/calendar_respond' +import { outlookCalendarUpdateEventTool } from '@/tools/outlook/calendar_update_event' + +const tools = [ + outlookCalendarListEventsTool, + outlookCalendarGetEventTool, + outlookCalendarCreateEventTool, + outlookCalendarUpdateEventTool, + outlookCalendarDeleteEventTool, + outlookCalendarRespondTool, +] + +describe('outlook calendar tools', () => { + it('every tool uses the outlook oauth provider and a snake_case id', () => { + for (const tool of tools) { + expect(tool.oauth).toEqual({ required: true, provider: 'outlook' }) + expect(tool.id).toMatch(/^outlook_calendar_[a-z_]+$/) + expect(tool.version).toBe('1.0.0') + expect(tool.outputs?.message).toBeDefined() + } + }) + + it('exposes the expected tool ids', () => { + expect(tools.map((t) => t.id)).toEqual([ + 'outlook_calendar_list_events', + 'outlook_calendar_get_event', + 'outlook_calendar_create_event', + 'outlook_calendar_update_event', + 'outlook_calendar_delete_event', + 'outlook_calendar_respond', + ]) + }) + + it('builds a calendarView URL with the time window and paging', () => { + const url = outlookCalendarListEventsTool.request.url as (p: unknown) => string + const built = url({ + accessToken: 't', + startDateTime: '2025-06-03T00:00:00Z', + endDateTime: '2025-06-10T00:00:00Z', + maxResults: 5, + }) + expect(built).toContain('https://graph.microsoft.com/v1.0/me/calendarView') + expect(built).toContain('startDateTime=2025-06-03T00%3A00%3A00Z') + expect(built).toContain('%24top=5') + expect(built).toContain('%24orderby=start%2FdateTime') + + // A Graph-origin nextLink page token is accepted. + expect(url({ accessToken: 't', pageToken: 'https://graph.microsoft.com/next' })).toBe( + 'https://graph.microsoft.com/next' + ) + }) + + it('scopes list and create to a selected calendar, defaulting to the default calendar', () => { + const listUrl = outlookCalendarListEventsTool.request.url as (p: unknown) => string + const createUrl = outlookCalendarCreateEventTool.request.url as (p: unknown) => string + const window = { + accessToken: 't', + startDateTime: '2025-06-03T00:00:00Z', + endDateTime: '2025-06-10T00:00:00Z', + } + + expect(listUrl(window)).toContain('/v1.0/me/calendarView?') + expect(listUrl({ ...window, calendarId: 'AAMk=' })).toContain( + '/v1.0/me/calendars/AAMk%3D/calendarView?' + ) + // A blank pick means "default calendar", not a malformed /me/calendars// URL. + expect(listUrl({ ...window, calendarId: ' ' })).toContain('/v1.0/me/calendarView?') + + expect(createUrl({ accessToken: 't' })).toBe('https://graph.microsoft.com/v1.0/me/events') + expect(createUrl({ accessToken: 't', calendarId: 'AAMk=' })).toBe( + 'https://graph.microsoft.com/v1.0/me/calendars/AAMk%3D/events' + ) + }) + + it('treats date-only bounds as an all-day event even without the flag', () => { + const createBody = outlookCalendarCreateEventTool.request.body as ( + p: unknown + ) => Record + // Date-only carries no time, so this must not become a zero-length 00:00->00:00 window. + const created = createBody({ + accessToken: 't', + subject: 'Company holiday', + startDateTime: '2025-06-03', + endDateTime: '2025-06-03', + }) + expect(created.isAllDay).toBe(true) + expect(created.start).toEqual({ dateTime: '2025-06-03T00:00:00', timeZone: 'UTC' }) + expect(created.end).toEqual({ dateTime: '2025-06-04T00:00:00', timeZone: 'UTC' }) + + const updateBody = outlookCalendarUpdateEventTool.request.body as ( + p: unknown + ) => Record + const updated = updateBody({ + accessToken: 't', + eventId: 'e1', + startDateTime: '2025-06-03', + endDateTime: '2025-06-05', + }) + expect(updated.isAllDay).toBe(true) + expect(updated.start).toEqual({ dateTime: '2025-06-03T00:00:00', timeZone: 'UTC' }) + expect(updated.end).toEqual({ dateTime: '2025-06-05T00:00:00', timeZone: 'UTC' }) + + // A timed bound stays timed. + const timed = createBody({ + accessToken: 't', + subject: 'Sync', + startDateTime: '2025-06-03T10:00:00Z', + endDateTime: '2025-06-03T11:00:00Z', + }) + expect(timed.isAllDay).toBeUndefined() + + // Mixed date-only + timed is a timed event starting at midnight, not all-day. + const mixed = createBody({ + accessToken: 't', + subject: 'Sync', + startDateTime: '2025-06-03', + endDateTime: '2025-06-03T11:00:00Z', + }) + expect(mixed.isAllDay).toBeUndefined() + }) + + it('promotes date-only bounds even when isAllDay is explicitly false', () => { + // The block always sends isAllDay for create, so an untouched All Day switch arrives + // as `false`. Date-only bounds have no time, so honoring that false would emit a + // zero-length midnight window — the shape wins over the flag, by design. + const createBody = outlookCalendarCreateEventTool.request.body as ( + p: unknown + ) => Record + const created = createBody({ + accessToken: 't', + subject: 'Company holiday', + startDateTime: '2025-06-03', + endDateTime: '2025-06-03', + isAllDay: false, + }) + expect(created.isAllDay).toBe(true) + expect(created.end).toEqual({ dateTime: '2025-06-04T00:00:00', timeZone: 'UTC' }) + + const updateBody = outlookCalendarUpdateEventTool.request.body as ( + p: unknown + ) => Record + const updated = updateBody({ + accessToken: 't', + eventId: 'e1', + startDateTime: '2025-06-03', + endDateTime: '2025-06-04', + isAllDay: false, + }) + expect(updated.isAllDay).toBe(true) + }) + + it('does not promote a lone date-only bound on update', () => { + const body = outlookCalendarUpdateEventTool.request.body as ( + p: unknown + ) => Record + // Moving only the start of an existing timed/multi-day event must not collapse it into + // a one-day all-day event: the other bound is left untouched for Graph to preserve. + const movedStart = body({ accessToken: 't', eventId: 'e1', startDateTime: '2025-06-10' }) + expect(movedStart.isAllDay).toBeUndefined() + expect(movedStart.start).toEqual({ dateTime: '2025-06-10T00:00:00', timeZone: 'UTC' }) + expect('end' in movedStart).toBe(false) + + const movedEnd = body({ accessToken: 't', eventId: 'e1', endDateTime: '2025-06-12' }) + expect(movedEnd.isAllDay).toBeUndefined() + expect('start' in movedEnd).toBe(false) + + // An explicit isAllDay:true is stated intent, so deriving the missing bound is allowed. + const explicit = body({ + accessToken: 't', + eventId: 'e1', + isAllDay: true, + startDateTime: '2025-06-10', + }) + expect(explicit.isAllDay).toBe(true) + expect(explicit.end).toEqual({ dateTime: '2025-06-11T00:00:00', timeZone: 'UTC' }) + }) + + it('rejects an all-day conversion that supplies no bounds', () => { + const body = outlookCalendarUpdateEventTool.request.body as ( + p: unknown + ) => Record + // Graph needs midnight bounds; they cannot be derived from a partial update. + expect(() => body({ accessToken: 't', eventId: 'e1', isAllDay: true })).toThrow( + /requires startDateTime/ + ) + // Turning all-day off without bounds is still a valid partial update. + expect(body({ accessToken: 't', eventId: 'e1', isAllDay: false })).toEqual({ isAllDay: false }) + }) + + it('normalizes an all-day update when only one bound is supplied', () => { + const body = outlookCalendarUpdateEventTool.request.body as ( + p: unknown + ) => Record + const built = body({ + accessToken: 't', + eventId: 'e1', + isAllDay: true, + startDateTime: '2025-06-03T09:30:00Z', + }) + // Both bounds must be midnight with an exclusive end day, or Graph 400s the PATCH. + expect(built.start).toEqual({ dateTime: '2025-06-03T00:00:00', timeZone: 'UTC' }) + expect(built.end).toEqual({ dateTime: '2025-06-04T00:00:00', timeZone: 'UTC' }) + expect(built.isAllDay).toBe(true) + }) + + it('rejects a non-Graph pageToken so the bearer token cannot be exfiltrated', () => { + const url = outlookCalendarListEventsTool.request.url as (p: unknown) => string + expect(() => url({ accessToken: 't', pageToken: 'https://evil.example.com/steal' })).toThrow( + /Microsoft Graph/ + ) + }) + + it('builds an all-day create body with midnight bounds and an exclusive end day', () => { + const body = outlookCalendarCreateEventTool.request.body as ( + p: unknown + ) => Record + const built = body({ + accessToken: 't', + subject: 'Offsite', + startDateTime: '2025-06-03', + endDateTime: '2025-06-03', + isAllDay: true, + }) + expect(built.isAllDay).toBe(true) + expect(built.start).toEqual({ dateTime: '2025-06-03T00:00:00', timeZone: 'UTC' }) + expect(built.end).toEqual({ dateTime: '2025-06-04T00:00:00', timeZone: 'UTC' }) + }) + + it('builds a create-event body with Graph datetime shape and attendees', () => { + const body = outlookCalendarCreateEventTool.request.body as ( + p: unknown + ) => Record + const built = body({ + accessToken: 't', + subject: 'Sync', + startDateTime: '2025-06-03T10:00:00-08:00', + endDateTime: '2025-06-03T11:00:00-08:00', + attendees: 'a@x.com, b@y.com', + isOnlineMeeting: true, + }) + expect(built.subject).toBe('Sync') + expect(built.start).toEqual({ dateTime: '2025-06-03T18:00:00', timeZone: 'UTC' }) + expect(built.attendees).toHaveLength(2) + expect(built.isOnlineMeeting).toBe(true) + // onlineMeetingProvider is optional and pinning it would break mailboxes that + // disallow that provider; isOnlineMeeting alone initializes the online meeting. + expect('onlineMeetingProvider' in built).toBe(false) + }) + + it('rejects an invalid respond responseType', () => { + const url = outlookCalendarRespondTool.request.url as (p: unknown) => string + expect(() => url({ accessToken: 't', eventId: 'e1', responseType: 'maybe' })).toThrow( + /Invalid responseType/ + ) + expect(url({ accessToken: 't', eventId: 'e1', responseType: 'accept' })).toBe( + 'https://graph.microsoft.com/v1.0/me/events/e1/accept' + ) + }) + + it('requires both window bounds unless paging with a pageToken', () => { + const url = outlookCalendarListEventsTool.request.url as (p: unknown) => string + // Paging supplies only the token: the window is already baked into the nextLink. + expect(url({ accessToken: 't', pageToken: 'https://graph.microsoft.com/next' })).toBe( + 'https://graph.microsoft.com/next' + ) + expect(() => url({ accessToken: 't', startDateTime: '2025-06-03T00:00:00Z' })).toThrow( + /startDateTime and endDateTime are required/ + ) + expect(() => url({ accessToken: 't' })).toThrow(/startDateTime and endDateTime are required/) + }) + + it('omits the comment key when empty but keeps it alongside sendResponse=false', () => { + const body = outlookCalendarRespondTool.request.body as (p: unknown) => Record + // Empty/whitespace comment with sendResponse off: comment must NOT be present. + const noComment = body({ + eventId: 'e1', + responseType: 'accept', + sendResponse: false, + comment: '', + }) + expect(noComment).toEqual({ sendResponse: false }) + expect('comment' in noComment).toBe(false) + + const nullComment = body({ eventId: 'e1', responseType: 'decline', sendResponse: false }) + expect('comment' in nullComment).toBe(false) + + // A real comment with sendResponse=false is valid: Graph's documented 400s for + // accept/decline are both about proposedNewTime, which we never send. + expect( + body({ eventId: 'e1', responseType: 'decline', sendResponse: false, comment: 'Conflict' }) + ).toEqual({ sendResponse: false, comment: 'Conflict' }) + + // A real comment is included, and sendResponse defaults to true. + const withComment = body({ eventId: 'e1', responseType: 'accept', comment: 'See you there' }) + expect(withComment).toEqual({ sendResponse: true, comment: 'See you there' }) + }) + + it('sends a stable transactionId so a retried create cannot duplicate the event', () => { + const body = outlookCalendarCreateEventTool.request.body as ( + p: unknown + ) => Record + const params = { + accessToken: 't', + subject: 'Sync', + startDateTime: '2025-06-03T10:00:00Z', + endDateTime: '2025-06-03T11:00:00Z', + } + const first = body(params) + expect(typeof first.transactionId).toBe('string') + expect(first.transactionId).toBeTruthy() + + // Distinct executions must not share an id, or Graph would discard a genuine + // second event as a duplicate. (Retries reuse one body, built once per execution.) + expect(body(params).transactionId).not.toBe(first.transactionId) + }) + + it('enables retry with backoff on every calendar tool (429/mailbox concurrency)', () => { + for (const tool of tools) { + expect(tool.request.retry?.enabled).toBe(true) + // false so POST/PATCH (create/update/respond) also retry on a 429 throttle. + expect(tool.request.retry?.retryIdempotentOnly).toBe(false) + } + }) +}) diff --git a/apps/sim/tools/outlook/calendar-utils.test.ts b/apps/sim/tools/outlook/calendar-utils.test.ts new file mode 100644 index 00000000000..f7c9663c675 --- /dev/null +++ b/apps/sim/tools/outlook/calendar-utils.test.ts @@ -0,0 +1,165 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildAllDayRange, + buildGraphEventDateTime, + DEFAULT_OUTLOOK_TIME_ZONE, + flattenGraphEvent, + isDateOnly, + normalizeAttendees, +} from '@/tools/outlook/calendar-utils' +import type { GraphEvent } from '@/tools/outlook/types' + +describe('date-only detection', () => { + it('matches only a bare YYYY-MM-DD', () => { + expect(isDateOnly('2025-06-03')).toBe(true) + expect(isDateOnly(' 2025-06-03 ')).toBe(true) + expect(isDateOnly(undefined)).toBe(false) + expect(isDateOnly('2025-06-03T10:00:00Z')).toBe(false) + // Lacks a `T` but carries a time — must not be mistaken for an all-day bound, or the + // time would be discarded and the value would build as `... 10:00T00:00:00`. + expect(isDateOnly('2025-06-03 10:00')).toBe(false) + }) + + it('normalizes a space-separated datetime instead of mangling it', () => { + expect(buildGraphEventDateTime('2025-06-03 10:00')).toEqual({ + dateTime: '2025-06-03T10:00', + timeZone: 'UTC', + }) + expect(buildGraphEventDateTime('2025-06-03 10:00:30', 'America/Los_Angeles')).toEqual({ + dateTime: '2025-06-03T10:00:30', + timeZone: 'America/Los_Angeles', + }) + }) +}) + +describe('buildGraphEventDateTime', () => { + it('treats a date-only value as an all-day midnight start in the given zone', () => { + expect(buildGraphEventDateTime('2025-06-03', 'America/Los_Angeles')).toEqual({ + dateTime: '2025-06-03T00:00:00', + timeZone: 'America/Los_Angeles', + }) + }) + + it('defaults the zone to UTC when none is given for a date-only value', () => { + expect(buildGraphEventDateTime('2025-06-03')).toEqual({ + dateTime: '2025-06-03T00:00:00', + timeZone: DEFAULT_OUTLOOK_TIME_ZONE, + }) + }) + + it('keeps a naive datetime as wall-clock time in the provided zone', () => { + expect(buildGraphEventDateTime('2025-06-03T10:00:00', 'America/New_York')).toEqual({ + dateTime: '2025-06-03T10:00:00', + timeZone: 'America/New_York', + }) + }) + + it('converts a Z-suffixed datetime to an offset-less UTC value', () => { + expect(buildGraphEventDateTime('2025-06-03T10:00:00Z')).toEqual({ + dateTime: '2025-06-03T10:00:00', + timeZone: 'UTC', + }) + }) + + it('converts an offset datetime to the equivalent UTC instant', () => { + // 10:00 at -08:00 is 18:00 UTC + expect(buildGraphEventDateTime('2025-06-03T10:00:00-08:00')).toEqual({ + dateTime: '2025-06-03T18:00:00', + timeZone: 'UTC', + }) + }) +}) + +describe('buildAllDayRange', () => { + it('advances a same-day end to the next day (exclusive end) at midnight', () => { + expect(buildAllDayRange('2025-06-03', '2025-06-03', 'America/Los_Angeles')).toEqual({ + start: { dateTime: '2025-06-03T00:00:00', timeZone: 'America/Los_Angeles' }, + end: { dateTime: '2025-06-04T00:00:00', timeZone: 'America/Los_Angeles' }, + }) + }) + + it('preserves an already-multi-day range and strips any time component', () => { + expect(buildAllDayRange('2025-06-03T09:30:00', '2025-06-05T14:00:00')).toEqual({ + start: { dateTime: '2025-06-03T00:00:00', timeZone: DEFAULT_OUTLOOK_TIME_ZONE }, + end: { dateTime: '2025-06-05T00:00:00', timeZone: DEFAULT_OUTLOOK_TIME_ZONE }, + }) + }) + + it('advances across a month boundary', () => { + expect(buildAllDayRange('2025-06-30', '2025-06-30').end.dateTime).toBe('2025-07-01T00:00:00') + }) +}) + +describe('normalizeAttendees', () => { + it('returns an empty array for undefined', () => { + expect(normalizeAttendees(undefined)).toEqual([]) + }) + + it('splits a comma-separated string into required attendees', () => { + expect(normalizeAttendees('a@x.com, b@y.com')).toEqual([ + { emailAddress: { address: 'a@x.com' }, type: 'required' }, + { emailAddress: { address: 'b@y.com' }, type: 'required' }, + ]) + }) + + it('accepts an array and honors a custom type', () => { + expect(normalizeAttendees(['a@x.com'], 'optional')).toEqual([ + { emailAddress: { address: 'a@x.com' }, type: 'optional' }, + ]) + }) + + it('drops empty entries', () => { + expect(normalizeAttendees('a@x.com,,\n , b@y.com')).toEqual([ + { emailAddress: { address: 'a@x.com' }, type: 'required' }, + { emailAddress: { address: 'b@y.com' }, type: 'required' }, + ]) + }) +}) + +describe('flattenGraphEvent', () => { + it('flattens the Graph event into editor-friendly fields', () => { + const event: GraphEvent = { + id: 'evt-1', + subject: 'Standup', + bodyPreview: 'Daily sync', + start: { dateTime: '2025-06-03T10:00:00.0000000', timeZone: 'UTC' }, + end: { dateTime: '2025-06-03T10:30:00.0000000', timeZone: 'UTC' }, + isAllDay: false, + webLink: 'https://outlook.office.com/evt-1', + location: { displayName: 'Room 1' }, + organizer: { emailAddress: { name: 'Alice', address: 'alice@x.com' } }, + onlineMeeting: { joinUrl: 'https://teams.example/join' }, + attendees: [ + { + type: 'required', + status: { response: 'accepted' }, + emailAddress: { name: 'Bob', address: 'bob@y.com' }, + }, + ], + } + + expect(flattenGraphEvent(event)).toEqual({ + id: 'evt-1', + subject: 'Standup', + bodyPreview: 'Daily sync', + start: { dateTime: '2025-06-03T10:00:00.0000000', timeZone: 'UTC' }, + end: { dateTime: '2025-06-03T10:30:00.0000000', timeZone: 'UTC' }, + isAllDay: false, + location: 'Room 1', + organizer: { name: 'Alice', address: 'alice@x.com' }, + attendees: [{ name: 'Bob', address: 'bob@y.com', type: 'required', response: 'accepted' }], + onlineMeeting: { joinUrl: 'https://teams.example/join' }, + webLink: 'https://outlook.office.com/evt-1', + }) + }) + + it('returns a null onlineMeeting when the event has no join URL', () => { + const event: GraphEvent = { id: 'evt-2' } + const flattened = flattenGraphEvent(event) + expect(flattened.onlineMeeting).toBeNull() + expect(flattened.attendees).toEqual([]) + }) +}) diff --git a/apps/sim/tools/outlook/calendar-utils.ts b/apps/sim/tools/outlook/calendar-utils.ts new file mode 100644 index 00000000000..1be0a1a3ff6 --- /dev/null +++ b/apps/sim/tools/outlook/calendar-utils.ts @@ -0,0 +1,236 @@ +import type { + CleanedOutlookEvent, + CleanedOutlookEventAttendee, + GraphAttendee, + GraphDateTimeTimeZone, + GraphEvent, +} from '@/tools/outlook/types' +import type { ToolRetryConfig } from '@/tools/types' + +/** + * Shared retry config for the Outlook calendar tools. + * + * Microsoft Graph throttles a single mailbox at roughly four concurrent requests and + * returns 429 with a `Retry-After` header when calendar operations fan out (e.g. many + * parallel event creates in a workflow). `retryIdempotentOnly` is `false` so + * create/update/respond (POST/PATCH) retry too, rather than failing the whole workflow + * on a throttle. + * + * The executor retries **429 and 5xx** (`isRetryableFailure`), not 429 alone, so each + * non-idempotent method has to be safe against a retry that follows an already-committed + * write: + * - `create_event` (POST) sends a per-execution `transactionId`; Graph uses it to discard + * the duplicate POST, so a 5xx-after-commit cannot create a second event. + * - `update_event` (PATCH) resends the same partial body, so replaying it is a no-op. + * - `respond` (POST accept/decline) is state-idempotent — the resulting `responseStatus` + * is identical — but a retry after a committed write can send the organizer a second + * notification email. Accepted: a duplicate notification is less harmful than failing + * the response outright under throttling, and Graph exposes no transactionId for it. + */ +export const CALENDAR_RETRY: ToolRetryConfig = { + enabled: true, + maxRetries: 3, + initialDelayMs: 500, + maxDelayMs: 30000, + retryIdempotentOnly: false, +} + +const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0' + +/** + * Build the Graph URL for a calendar-scoped collection. + * + * Graph exposes the mailbox's default calendar at `/me/{collection}` and any other calendar + * at `/me/calendars/{id}/{collection}`. An empty/blank `calendarId` means "default calendar". + * + * @see https://learn.microsoft.com/en-us/graph/api/calendar-list-calendarview + * @see https://learn.microsoft.com/en-us/graph/api/user-post-events + */ +export function buildCalendarScopedUrl(collection: 'events' | 'calendarView', calendarId?: string) { + const id = calendarId?.trim() + return id + ? `${GRAPH_BASE_URL}/me/calendars/${encodeURIComponent(id)}/${collection}` + : `${GRAPH_BASE_URL}/me/${collection}` +} + +/** + * Build the Graph URL for a single event. + * + * Event IDs are unique within the mailbox, so `/me/events/{id}` addresses an event in any of + * the user's calendars — no calendar scoping is needed for get / update / delete / respond. + */ +export function buildEventUrl(eventId: string, suffix?: string) { + const base = `${GRAPH_BASE_URL}/me/events/${encodeURIComponent(eventId.trim())}` + return suffix ? `${base}/${suffix}` : base +} + +/** Matches a trailing UTC offset (`Z`, `+02:00`, `-0800`) on an ISO datetime string. */ +const TZ_OFFSET_PATTERN = /([+-]\d{2}:?\d{2}|Z)$/ + +/** Matches the milliseconds + `Z` suffix produced by `Date.toISOString()`. */ +const ISO_MS_ZULU_PATTERN = /\.\d{3}Z$/ + +/** Time zone recorded when the caller supplies none and the datetime carries no offset. */ +export const DEFAULT_OUTLOOK_TIME_ZONE = 'UTC' + +/** Attendee category understood by Microsoft Graph. */ +export type GraphAttendeeType = 'required' | 'optional' | 'resource' + +/** Attendee payload shape Microsoft Graph expects on event create/update. */ +export interface GraphAttendeeInput { + emailAddress: { address: string } + type: GraphAttendeeType +} + +/** + * Build a Microsoft Graph `{ dateTime, timeZone }` value from a user-supplied datetime. + * + * Graph expects an offset-less `dateTime` paired with a named `timeZone`, unlike Google + * Calendar which accepts the offset inside the string. Behavior: + * - Date-only input (`2025-06-03`) is pinned to midnight for an all-day event. + * - An input carrying a UTC offset (`...Z` or `+02:00`) is converted to the equivalent UTC + * instant so Graph interprets it unambiguously. + * - A naive datetime is treated as wall-clock time in the provided (or default) zone. + */ +export function buildGraphEventDateTime(value: string, timeZone?: string): GraphDateTimeTimeZone { + // Accept `YYYY-MM-DD HH:MM` as well as the ISO `T` form. Without this the space variant + // falls through to the date-only branch and yields `2025-06-03 10:00T00:00:00`, which + // Graph rejects — and it is a natural thing for a caller to type. + const trimmed = value.trim().replace(SPACE_SEPARATED_DATETIME_PATTERN, '$1T$2') + + if (!trimmed.includes('T')) { + return { dateTime: `${trimmed}T00:00:00`, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE } + } + + const offsetMatch = trimmed.match(TZ_OFFSET_PATTERN) + if (offsetMatch) { + const parsed = new Date(trimmed) + if (!Number.isNaN(parsed.getTime())) { + return { dateTime: parsed.toISOString().replace(ISO_MS_ZULU_PATTERN, ''), timeZone: 'UTC' } + } + // Unparseable offset: strip it and fall back to the caller's zone. + return { + dateTime: trimmed.slice(0, trimmed.length - offsetMatch[0].length), + timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE, + } + } + + return { dateTime: trimmed, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE } +} + +/** Exactly `YYYY-MM-DD`, with no time component. */ +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** `YYYY-MM-DD HH:MM[:SS]` — a space where ISO 8601 wants `T`. */ +const SPACE_SEPARATED_DATETIME_PATTERN = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)/ + +/** + * True when a bound carries a date but no time (`2025-06-03`). + * + * A date-only bound has no time component, so the only coherent reading is an all-day + * event — the create/update tools promote such a pair rather than emitting a zero-length + * midnight-to-midnight timed window, which Graph rejects. + * + * Matched strictly rather than as "contains no `T`": a space-separated datetime + * (`2025-06-03 10:00`) also lacks a `T`, and treating it as date-only would both discard + * the caller's time and build a malformed `2025-06-03 10:00T00:00:00` value. + */ +export function isDateOnly(value: string | undefined): boolean { + return Boolean(value) && DATE_ONLY_PATTERN.test(value!.trim()) +} + +/** Extract the `YYYY-MM-DD` date portion from a date or datetime string. */ +function toDateOnly(value: string): string { + const trimmed = value.trim() + const tIndex = trimmed.indexOf('T') + return tIndex === -1 ? trimmed : trimmed.slice(0, tIndex) +} + +/** Add one calendar day to a `YYYY-MM-DD` string. */ +function nextDay(dateOnly: string): string { + const d = new Date(`${dateOnly}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + 1) + return d.toISOString().slice(0, 10) +} + +/** + * Build the Graph `start`/`end` pair for an all-day event. + * + * Graph requires all-day events to have midnight `dateTime` values and an **exclusive** + * end day strictly after the start day. Callers routinely pass the same date (or timed + * placeholders) for both bounds, which Graph rejects with a 400. This normalizes both + * bounds to midnight and advances the end to at least the day after the start. + */ +export function buildAllDayRange( + startInput: string, + endInput: string, + timeZone?: string +): { start: GraphDateTimeTimeZone; end: GraphDateTimeTimeZone } { + const tz = timeZone || DEFAULT_OUTLOOK_TIME_ZONE + const startDate = toDateOnly(startInput) + let endDate = toDateOnly(endInput) + // `YYYY-MM-DD` strings compare correctly lexicographically. + if (endDate <= startDate) { + endDate = nextDay(startDate) + } + return { + start: { dateTime: `${startDate}T00:00:00`, timeZone: tz }, + end: { dateTime: `${endDate}T00:00:00`, timeZone: tz }, + } +} + +/** + * Normalize a comma/newline-separated string (or array) of email addresses into the Graph + * attendee payload shape. + */ +export function normalizeAttendees( + attendees: string | string[] | undefined, + type: GraphAttendeeType = 'required' +): GraphAttendeeInput[] { + if (!attendees) return [] + + const list = Array.isArray(attendees) ? attendees : String(attendees).split(/[,\n]/) + + return list + .map((address) => address.trim()) + .filter(Boolean) + .map((address) => ({ emailAddress: { address }, type })) +} + +/** Flatten a raw Graph attendee into our cleaned attendee shape. */ +function flattenGraphAttendee(attendee: GraphAttendee): CleanedOutlookEventAttendee { + return { + name: attendee.emailAddress?.name, + address: attendee.emailAddress?.address, + type: attendee.type, + response: attendee.status?.response, + } +} + +/** + * Flatten a raw Microsoft Graph event into the cleaned, editor-friendly event shape our + * tools return: `id, subject, start, end, organizer, attendees, isAllDay, onlineMeeting, + * webLink, bodyPreview`. + */ +export function flattenGraphEvent(event: GraphEvent): CleanedOutlookEvent { + return { + id: event.id, + subject: event.subject, + bodyPreview: event.bodyPreview, + start: event.start + ? { dateTime: event.start.dateTime, timeZone: event.start.timeZone } + : undefined, + end: event.end ? { dateTime: event.end.dateTime, timeZone: event.end.timeZone } : undefined, + isAllDay: event.isAllDay, + location: event.location?.displayName, + organizer: event.organizer?.emailAddress + ? { + name: event.organizer.emailAddress.name, + address: event.organizer.emailAddress.address, + } + : undefined, + attendees: (event.attendees ?? []).map(flattenGraphAttendee), + onlineMeeting: event.onlineMeeting?.joinUrl ? { joinUrl: event.onlineMeeting.joinUrl } : null, + webLink: event.webLink, + } +} diff --git a/apps/sim/tools/outlook/calendar_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts new file mode 100644 index 00000000000..1180166d4ae --- /dev/null +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -0,0 +1,207 @@ +import { generateId } from '@sim/utils/id' +import { ErrorExtractorId } from '@/tools/error-extractors' +import { + buildAllDayRange, + buildCalendarScopedUrl, + buildGraphEventDateTime, + CALENDAR_RETRY, + flattenGraphEvent, + isDateOnly, + normalizeAttendees, +} from '@/tools/outlook/calendar-utils' +import type { + GraphEvent, + OutlookCalendarCreateEventParams, + OutlookCalendarCreateEventResponse, +} from '@/tools/outlook/types' +import { OUTLOOK_EVENT_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +/** Agent calls may deliver booleans as the strings "true"/"false". */ +const toBool = (value: unknown): boolean => value === true || value === 'true' + +export const outlookCalendarCreateEventTool: ToolConfig< + OutlookCalendarCreateEventParams, + OutlookCalendarCreateEventResponse +> = { + id: 'outlook_calendar_create_event', + name: 'Outlook Create Calendar Event', + description: 'Create a new Outlook calendar event', + version: '1.0.0', + + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'ID of the calendar to create the event in. Defaults to the mailbox default calendar. Calendars shared by another user are not supported and may return 403.', + }, + subject: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Event subject/title', + }, + startDateTime: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Start time (ISO 8601, e.g. 2025-06-03T10:00:00-08:00). A date-only value (2025-06-03) for both start and end creates an all-day event.', + }, + endDateTime: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'End time (ISO 8601, e.g. 2025-06-03T11:00:00-08:00). A date-only value (2025-06-04) for both start and end creates an all-day event.', + }, + timeZone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'IANA or Windows time zone name (e.g. America/Los_Angeles). Used for datetimes without a UTC offset. Defaults to UTC.', + }, + body: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Event body content', + }, + contentType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Content type for the event body (text or html)', + }, + location: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Event location display name', + }, + attendees: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Attendee email addresses (comma-separated)', + }, + isAllDay: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the event lasts the entire day', + }, + isOnlineMeeting: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows (Teams on work/school accounts); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there.', + }, + }, + + request: { + url: (params) => buildCalendarScopedUrl('events', params.calendarId), + method: 'POST', + retry: CALENDAR_RETRY, + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + // Date-only bounds carry no time, so they can only mean an all-day event. Without + // this promotion, `2025-06-03` for both bounds would build a zero-length + // 00:00->00:00 timed window that Graph rejects, even though the param docs invite + // date-only input for all-day events. + const bothBoundsDateOnly = isDateOnly(params.startDateTime) && isDateOnly(params.endDateTime) + const isAllDay = toBool(params.isAllDay) || bothBoundsDateOnly + const event: Record = { + subject: params.subject, + // Graph de-duplicates create-event POSTs that repeat a transactionId, which is + // exactly the retry case: the executor retries 5xx as well as 429, so a failure + // returned after Graph already committed the event would otherwise create a + // duplicate. The request body is built once per execution and reused across + // attempts, so this id is stable for all retries of this call and unique across + // calls. https://learn.microsoft.com/en-us/graph/api/resources/event + transactionId: generateId(), + } + + if (isAllDay) { + // All-day events need midnight bounds with an exclusive end day. + const range = buildAllDayRange(params.startDateTime, params.endDateTime, params.timeZone) + event.isAllDay = true + event.start = range.start + event.end = range.end + } else { + event.start = buildGraphEventDateTime(params.startDateTime, params.timeZone) + event.end = buildGraphEventDateTime(params.endDateTime, params.timeZone) + } + + if (params.body) { + event.body = { contentType: params.contentType || 'text', content: params.body } + } + + if (params.location) { + event.location = { displayName: params.location } + } + + const attendees = normalizeAttendees(params.attendees) + if (attendees.length > 0) { + event.attendees = attendees + } + + if (toBool(params.isOnlineMeeting)) { + // Setting isOnlineMeeting alone is enough: Graph initializes `onlineMeeting` from + // it, and `onlineMeetingProvider` is optional. We deliberately do not pin + // teamsForBusiness, since that value is invalid on mailboxes that don't allow it + // (see calendar.allowedOnlineMeetingProviders). + // https://learn.microsoft.com/en-us/graph/api/resources/event + event.isOnlineMeeting = true + } + + return event + }, + }, + + transformResponse: async (response: Response) => { + const data: GraphEvent = await response.json() + + return { + success: true, + output: { + message: `Successfully created event "${data.subject ?? data.id}".`, + results: flattenGraphEvent(data), + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'object', + description: 'The created calendar event object', + properties: OUTLOOK_EVENT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/outlook/calendar_delete_event.ts b/apps/sim/tools/outlook/calendar_delete_event.ts new file mode 100644 index 00000000000..5b68c42d294 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_delete_event.ts @@ -0,0 +1,85 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { buildEventUrl, CALENDAR_RETRY } from '@/tools/outlook/calendar-utils' +import type { + OutlookCalendarDeleteEventParams, + OutlookCalendarDeleteEventResponse, +} from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +export const outlookCalendarDeleteEventTool: ToolConfig< + OutlookCalendarDeleteEventParams, + OutlookCalendarDeleteEventResponse +> = { + id: 'outlook_calendar_delete_event', + name: 'Outlook Delete Calendar Event', + description: 'Delete an Outlook calendar event by ID', + version: '1.0.0', + + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + eventId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the calendar event to delete', + }, + }, + + request: { + url: (params) => buildEventUrl(params.eventId), + method: 'DELETE', + retry: CALENDAR_RETRY, + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + }, + + // Graph returns 204 No Content on success, so there is no body to read. + transformResponse: async (response: Response, params?: OutlookCalendarDeleteEventParams) => { + const status = response.status + + return { + success: true, + output: { + message: + status === 204 || status === 200 + ? 'Event deleted successfully' + : `Event deleted (HTTP ${status})`, + results: { + eventId: params?.eventId ?? '', + status: 'deleted', + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'object', + description: 'Delete result details', + properties: { + eventId: { type: 'string', description: 'ID of the deleted event' }, + status: { type: 'string', description: 'Deletion status' }, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/calendar_get_event.ts b/apps/sim/tools/outlook/calendar_get_event.ts new file mode 100644 index 00000000000..15d2dcfd905 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_get_event.ts @@ -0,0 +1,77 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { buildEventUrl, CALENDAR_RETRY, flattenGraphEvent } from '@/tools/outlook/calendar-utils' +import type { + GraphEvent, + OutlookCalendarGetEventParams, + OutlookCalendarGetEventResponse, +} from '@/tools/outlook/types' +import { OUTLOOK_EVENT_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +export const outlookCalendarGetEventTool: ToolConfig< + OutlookCalendarGetEventParams, + OutlookCalendarGetEventResponse +> = { + id: 'outlook_calendar_get_event', + name: 'Outlook Get Calendar Event', + description: 'Get a single Outlook calendar event by ID', + version: '1.0.0', + + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + eventId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the calendar event to retrieve', + }, + }, + + request: { + url: (params) => buildEventUrl(params.eventId), + method: 'GET', + retry: CALENDAR_RETRY, + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: GraphEvent = await response.json() + + return { + success: true, + output: { + message: `Successfully retrieved event "${data.subject ?? data.id}".`, + results: flattenGraphEvent(data), + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'object', + description: 'The calendar event object', + properties: OUTLOOK_EVENT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/outlook/calendar_list_events.ts b/apps/sim/tools/outlook/calendar_list_events.ts new file mode 100644 index 00000000000..b73305b2071 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_list_events.ts @@ -0,0 +1,157 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { + buildCalendarScopedUrl, + CALENDAR_RETRY, + flattenGraphEvent, +} from '@/tools/outlook/calendar-utils' +import type { + GraphEventsResponse, + OutlookCalendarListEventsParams, + OutlookCalendarListEventsResponse, +} from '@/tools/outlook/types' +import { OUTLOOK_EVENT_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import { assertGraphNextPageUrl } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +/** Graph caps `$top` on calendarView at 1000; we cap lower to bound the response payload. */ +const MAX_EVENTS_PER_PAGE = 100 + +export const outlookCalendarListEventsTool: ToolConfig< + OutlookCalendarListEventsParams, + OutlookCalendarListEventsResponse +> = { + id: 'outlook_calendar_list_events', + name: 'Outlook List Calendar Events', + description: 'List Outlook calendar events within a start/end time window', + version: '1.0.0', + + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'ID of the calendar to read. Defaults to the mailbox default calendar. Calendars shared by another user are not supported and may return 403.', + }, + // Not `required`, because a paging call supplies only `pageToken` and the window bounds + // are baked into the nextLink. The url builder enforces "pageToken OR both bounds". + // Mirrors tools/sharepoint/list_sites.ts. + startDateTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Start of the time window (ISO 8601, e.g. 2025-06-03T00:00:00-08:00). Interpreted as UTC if no offset is given. Required unless paging with pageToken.', + }, + endDateTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'End of the time window (ISO 8601, e.g. 2025-06-10T00:00:00-08:00). Interpreted as UTC if no offset is given. Required unless paging with pageToken.', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of events to return per page (default: 10, max: 100)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order of events (default: start/dateTime)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Full @odata.nextLink URL from a previous page to continue paging', + }, + }, + + request: { + url: (params) => { + // A nextLink is a fully-formed Graph URL. Validate the origin before reusing it + // as the request URL — otherwise a workflow-supplied token would receive the + // Outlook bearer. Mirrors the onedrive / microsoft_ad Graph paging guard. + if (params.pageToken) { + return assertGraphNextPageUrl(params.pageToken.trim()) + } + + if (!params.startDateTime?.trim() || !params.endDateTime?.trim()) { + throw new Error( + 'startDateTime and endDateTime are required to list calendar events (calendarView needs both bounds). Supply pageToken instead to continue a previous page.' + ) + } + + const requested = Number(params.maxResults) + const maxResults = Number.isFinite(requested) + ? Math.max(1, Math.min(Math.abs(requested), MAX_EVENTS_PER_PAGE)) + : 10 + + const queryParams = new URLSearchParams() + queryParams.append('startDateTime', params.startDateTime.trim()) + queryParams.append('endDateTime', params.endDateTime.trim()) + queryParams.append('$top', String(maxResults)) + queryParams.append('$orderby', params.orderBy || 'start/dateTime') + + return `${buildCalendarScopedUrl('calendarView', params.calendarId)}?${queryParams.toString()}` + }, + method: 'GET', + retry: CALENDAR_RETRY, + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: GraphEventsResponse = await response.json() + const events = (data.value || []).map(flattenGraphEvent) + + return { + success: true, + output: { + message: `Successfully retrieved ${events.length} calendar event(s).`, + results: events, + nextLink: data['@odata.nextLink'], + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'array', + description: 'Array of calendar event objects', + items: { + type: 'object', + properties: OUTLOOK_EVENT_OUTPUT_PROPERTIES, + }, + }, + nextLink: { + type: 'string', + description: 'URL for the next page of results, if any', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/outlook/calendar_respond.ts b/apps/sim/tools/outlook/calendar_respond.ts new file mode 100644 index 00000000000..687e1068eb8 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_respond.ts @@ -0,0 +1,155 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { buildEventUrl, CALENDAR_RETRY } from '@/tools/outlook/calendar-utils' +import type { + OutlookCalendarRespondParams, + OutlookCalendarRespondResponse, + OutlookCalendarResponseType, +} from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +const VALID_RESPONSE_TYPES: readonly OutlookCalendarResponseType[] = [ + 'accept', + 'tentativelyAccept', + 'decline', +] + +/** Agent calls may deliver booleans as the strings "true"/"false". */ +const toBool = (value: unknown, fallback: boolean): boolean => { + if (value === undefined || value === null || value === '') return fallback + return value === true || value === 'true' +} + +export const outlookCalendarRespondTool: ToolConfig< + OutlookCalendarRespondParams, + OutlookCalendarRespondResponse +> = { + id: 'outlook_calendar_respond', + name: 'Outlook Respond to Calendar Invite', + description: 'Accept, tentatively accept, or decline an Outlook calendar event invitation', + version: '1.0.0', + + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + eventId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the calendar event to respond to', + }, + responseType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The response: accept, tentativelyAccept, or decline', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional comment to include with the response', + }, + sendResponse: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to send a response to the organizer (default: true)', + }, + }, + + request: { + url: (params) => { + const responseType = params.responseType as OutlookCalendarResponseType + if (!VALID_RESPONSE_TYPES.includes(responseType)) { + throw new Error( + `Invalid responseType "${params.responseType}". Expected one of: ${VALID_RESPONSE_TYPES.join(', ')}` + ) + } + return buildEventUrl(params.eventId, responseType) + }, + method: 'POST', + retry: CALENDAR_RETRY, + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + // Only send `comment` when it has content — an empty string is meaningless to the + // organizer. Graph documents exactly two 400 conditions for accept/decline, both on + // `proposedNewTime` (which we never send); `comment` carries no such restriction and + // is valid alongside sendResponse=false. + // https://learn.microsoft.com/en-us/graph/api/event-decline + const body: Record = { + sendResponse: toBool(params.sendResponse, true), + } + const comment = params.comment?.trim() + if (comment) { + body.comment = comment + } + return body + }, + }, + + // Graph returns 202 Accepted on success with no body. + transformResponse: async (response: Response, params?: OutlookCalendarRespondParams) => { + const status = response.status + const requestId = + response.headers?.get('request-id') || response.headers?.get('x-ms-request-id') || undefined + + return { + success: true, + output: { + message: + status === 202 || status === 200 + ? 'Response sent successfully' + : `Response sent (HTTP ${status})`, + results: { + eventId: params?.eventId ?? '', + responseType: (params?.responseType as OutlookCalendarResponseType) ?? 'accept', + status: 'responded', + httpStatus: status, + requestId, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'object', + description: 'Response result details', + properties: { + eventId: { type: 'string', description: 'ID of the event responded to' }, + responseType: { type: 'string', description: 'The response that was sent' }, + status: { type: 'string', description: 'Response status' }, + httpStatus: { + type: 'number', + description: 'HTTP status code returned by the API', + optional: true, + }, + requestId: { + type: 'string', + description: 'Microsoft Graph request-id header for tracing', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/calendar_update_event.ts b/apps/sim/tools/outlook/calendar_update_event.ts new file mode 100644 index 00000000000..c2806ec1d17 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -0,0 +1,225 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { + buildAllDayRange, + buildEventUrl, + buildGraphEventDateTime, + CALENDAR_RETRY, + flattenGraphEvent, + isDateOnly, + normalizeAttendees, +} from '@/tools/outlook/calendar-utils' +import type { + GraphEvent, + OutlookCalendarUpdateEventParams, + OutlookCalendarUpdateEventResponse, +} from '@/tools/outlook/types' +import { OUTLOOK_EVENT_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +/** Agent calls may deliver booleans as the strings "true"/"false". */ +const toBool = (value: unknown): boolean => value === true || value === 'true' + +export const outlookCalendarUpdateEventTool: ToolConfig< + OutlookCalendarUpdateEventParams, + OutlookCalendarUpdateEventResponse +> = { + id: 'outlook_calendar_update_event', + name: 'Outlook Update Calendar Event', + description: 'Update an existing Outlook calendar event', + version: '1.0.0', + + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + eventId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the calendar event to update', + }, + subject: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New event subject/title', + }, + startDateTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'New start time (ISO 8601). A date-only value (2025-06-03) converts the event to all-day.', + }, + endDateTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'New end time (ISO 8601). A date-only value (2025-06-04) converts the event to all-day.', + }, + timeZone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'IANA or Windows time zone name applied to updated datetimes without a UTC offset. Defaults to UTC.', + }, + body: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New event body content', + }, + contentType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Content type for the event body (text or html)', + }, + location: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New event location display name', + }, + attendees: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Replacement attendee email addresses (comma-separated)', + }, + isAllDay: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether the event lasts the entire day. Setting this true requires also sending startDateTime, since Graph needs midnight bounds.', + }, + isOnlineMeeting: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows (Teams on work/school accounts); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there.', + }, + }, + + request: { + url: (params) => buildEventUrl(params.eventId), + method: 'PATCH', + retry: CALENDAR_RETRY, + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + // PATCH is a partial update: only include fields the caller actually provided. + const event: Record = {} + + const providedBounds = [params.startDateTime, params.endDateTime].filter(Boolean) + const explicitAllDay = params.isAllDay !== undefined && toBool(params.isAllDay) + // Promote implicitly only when BOTH bounds are date-only (matching + // calendar_create_event). A *single* date-only bound is ambiguous — it could mean + // "convert to all-day" or "just move the start" — and a PATCH cannot read the + // event's existing bounds to tell. Deriving the missing side would silently collapse + // a timed or multi-day event into a one-day all-day event, so a lone date-only bound + // stays on the timed path and leaves the other side untouched. + const bothBoundsDateOnly = isDateOnly(params.startDateTime) && isDateOnly(params.endDateTime) + const settingAllDay = explicitAllDay || bothBoundsDateOnly + + if (params.subject !== undefined) { + event.subject = params.subject + } + + if (settingAllDay) { + // Graph requires an all-day event's start and end to both be midnight in the same + // zone, so flipping isAllDay on without bounds would 400 against whatever timed + // start/end the event already has. Fail with an actionable message instead. + if (providedBounds.length === 0) { + throw new Error( + 'Converting an event to all-day requires startDateTime (and preferably endDateTime): Microsoft Graph requires all-day events to have midnight start and end bounds, which cannot be derived from a partial update.' + ) + } + // Normalize both bounds together. The single-bound fallback only applies when the + // caller set isAllDay explicitly — implicit promotion requires both bounds — so + // deriving the missing side follows stated intent rather than guessing at it. + const start = params.startDateTime || params.endDateTime! + const end = params.endDateTime || params.startDateTime! + const range = buildAllDayRange(start, end, params.timeZone) + event.start = range.start + event.end = range.end + } else { + if (params.startDateTime) { + event.start = buildGraphEventDateTime(params.startDateTime, params.timeZone) + } + if (params.endDateTime) { + event.end = buildGraphEventDateTime(params.endDateTime, params.timeZone) + } + } + + if (params.body !== undefined) { + event.body = { contentType: params.contentType || 'text', content: params.body } + } + + if (params.location !== undefined) { + event.location = { displayName: params.location } + } + + if (params.attendees !== undefined) { + event.attendees = normalizeAttendees(params.attendees) + } + + if (settingAllDay) { + event.isAllDay = true + } else if (params.isAllDay !== undefined) { + event.isAllDay = toBool(params.isAllDay) + } + + if (params.isOnlineMeeting !== undefined) { + // See calendar_create_event: isOnlineMeeting alone initializes `onlineMeeting`, and + // pinning a provider would break mailboxes that disallow it. Note Graph ignores + // further changes once a meeting has been made online. + event.isOnlineMeeting = toBool(params.isOnlineMeeting) + } + + return event + }, + }, + + transformResponse: async (response: Response) => { + const data: GraphEvent = await response.json() + + return { + success: true, + output: { + message: `Successfully updated event "${data.subject ?? data.id}".`, + results: flattenGraphEvent(data), + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'object', + description: 'The updated calendar event object', + properties: OUTLOOK_EVENT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/outlook/index.ts b/apps/sim/tools/outlook/index.ts index f8cc4934374..3fbac58691e 100644 --- a/apps/sim/tools/outlook/index.ts +++ b/apps/sim/tools/outlook/index.ts @@ -1,3 +1,9 @@ +import { outlookCalendarCreateEventTool } from '@/tools/outlook/calendar_create_event' +import { outlookCalendarDeleteEventTool } from '@/tools/outlook/calendar_delete_event' +import { outlookCalendarGetEventTool } from '@/tools/outlook/calendar_get_event' +import { outlookCalendarListEventsTool } from '@/tools/outlook/calendar_list_events' +import { outlookCalendarRespondTool } from '@/tools/outlook/calendar_respond' +import { outlookCalendarUpdateEventTool } from '@/tools/outlook/calendar_update_event' import { outlookCopyTool } from '@/tools/outlook/copy' import { outlookCreateFolderTool } from '@/tools/outlook/create_folder' import { outlookDeleteTool } from '@/tools/outlook/delete' @@ -17,6 +23,12 @@ import { outlookSendTool } from '@/tools/outlook/send' import { outlookUpdateMessageTool } from '@/tools/outlook/update_message' export { + outlookCalendarCreateEventTool, + outlookCalendarDeleteEventTool, + outlookCalendarGetEventTool, + outlookCalendarListEventsTool, + outlookCalendarRespondTool, + outlookCalendarUpdateEventTool, outlookCopyTool, outlookCreateFolderTool, outlookDeleteTool, diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index 31856966d7d..4e1360b8f9f 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -340,6 +340,7 @@ export type OutlookExtendedResponse = | OutlookMarkReadResponse | OutlookDeleteResponse | OutlookCopyResponse + | OutlookCalendarResponse /** * Output definition for mail folder objects. @@ -522,3 +523,310 @@ export interface OutlookUpdateMessageResponse extends ToolResponse { } } } + +/** + * Calendar output-property definitions for Microsoft Graph event responses. + * @see https://learn.microsoft.com/en-us/graph/api/resources/event + */ + +/** + * Output definition for a Graph dateTimeTimeZone value. + * @see https://learn.microsoft.com/en-us/graph/api/resources/datetimetimezone + */ +export const OUTLOOK_EVENT_DATETIME_OUTPUT_PROPERTIES = { + dateTime: { + type: 'string', + description: 'Local date and time (ISO 8601, no offset)', + optional: true, + }, + timeZone: { type: 'string', description: 'IANA or Windows time zone name', optional: true }, +} as const satisfies Record + +/** + * Output definition for a flattened event attendee. + * @see https://learn.microsoft.com/en-us/graph/api/resources/attendee + */ +export const OUTLOOK_EVENT_ATTENDEE_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Attendee display name', optional: true }, + address: { type: 'string', description: 'Attendee email address', optional: true }, + type: { + type: 'string', + description: 'Attendee type (required, optional, or resource)', + optional: true, + }, + response: { + type: 'string', + description: 'Attendee response status (none, accepted, declined, tentativelyAccepted, ...)', + optional: true, + }, +} as const satisfies Record + +/** Output definition for the flattened online-meeting info. */ +export const OUTLOOK_EVENT_ONLINE_MEETING_OUTPUT_PROPERTIES = { + joinUrl: { type: 'string', description: 'URL to join the online meeting', optional: true }, +} as const satisfies Record + +/** Output definition for a cleaned calendar event returned by our tools. */ +export const OUTLOOK_EVENT_OUTPUT_PROPERTIES = { + id: { type: 'string', description: 'Unique event identifier' }, + subject: { type: 'string', description: 'Event subject/title', optional: true }, + bodyPreview: { type: 'string', description: 'Preview of the event body', optional: true }, + start: { + type: 'object', + description: 'Event start', + optional: true, + properties: OUTLOOK_EVENT_DATETIME_OUTPUT_PROPERTIES, + }, + end: { + type: 'object', + description: 'Event end', + optional: true, + properties: OUTLOOK_EVENT_DATETIME_OUTPUT_PROPERTIES, + }, + isAllDay: { + type: 'boolean', + description: 'Whether the event lasts the entire day', + optional: true, + }, + location: { type: 'string', description: 'Event location display name', optional: true }, + organizer: { + type: 'object', + description: 'Event organizer', + optional: true, + properties: OUTLOOK_EMAIL_ADDRESS_OUTPUT_PROPERTIES, + }, + attendees: { + type: 'array', + description: 'Event attendees', + items: { + type: 'object', + properties: OUTLOOK_EVENT_ATTENDEE_OUTPUT_PROPERTIES, + }, + }, + onlineMeeting: { + type: 'object', + description: 'Online-meeting join details, if any', + optional: true, + properties: OUTLOOK_EVENT_ONLINE_MEETING_OUTPUT_PROPERTIES, + }, + webLink: { + type: 'string', + description: 'URL that opens the event in Outlook on the web', + optional: true, + }, +} as const satisfies Record + +/** Cleaned dateTimeTimeZone value returned by our tools. */ +export interface CleanedOutlookEventDateTime { + dateTime?: string + timeZone?: string +} + +/** Cleaned event attendee returned by our tools. */ +export interface CleanedOutlookEventAttendee { + name?: string + address?: string + type?: string + response?: string +} + +/** Cleaned calendar event returned by our tools. */ +export interface CleanedOutlookEvent { + id: string + subject?: string + bodyPreview?: string + start?: CleanedOutlookEventDateTime + end?: CleanedOutlookEventDateTime + isAllDay?: boolean + location?: string + organizer?: { + name?: string + address?: string + } + attendees: CleanedOutlookEventAttendee[] + onlineMeeting?: { + joinUrl?: string + } | null + webLink?: string +} + +/** Raw Microsoft Graph dateTimeTimeZone value. */ +export interface GraphDateTimeTimeZone { + dateTime: string + timeZone?: string +} + +/** Raw Microsoft Graph emailAddress value. */ +export interface GraphEmailAddress { + name?: string + address?: string +} + +/** Raw Microsoft Graph attendee value. */ +export interface GraphAttendee { + type?: string + status?: { + response?: string + time?: string + } + emailAddress?: GraphEmailAddress +} + +/** Raw Microsoft Graph event resource (subset of fields we consume). */ +export interface GraphEvent { + id: string + subject?: string + bodyPreview?: string + body?: { + contentType?: string + content?: string + } + start?: GraphDateTimeTimeZone + end?: GraphDateTimeTimeZone + isAllDay?: boolean + isOnlineMeeting?: boolean + onlineMeeting?: { + joinUrl?: string + } | null + webLink?: string + location?: { + displayName?: string + } + organizer?: { + emailAddress?: GraphEmailAddress + } + attendees?: GraphAttendee[] + '@odata.etag'?: string +} + +/** Raw Microsoft Graph list response for events / calendarView. */ +export interface GraphEventsResponse { + '@odata.context'?: string + '@odata.nextLink'?: string + value: GraphEvent[] +} + +export interface OutlookCalendarListEventsParams { + accessToken: string + /** Calendar to read. Omit for the mailbox's default calendar. */ + calendarId?: string + /** Required unless `pageToken` is supplied, which already encodes the window. */ + startDateTime?: string + /** Required unless `pageToken` is supplied, which already encodes the window. */ + endDateTime?: string + maxResults?: number + orderBy?: string + /** Full `@odata.nextLink` URL from a previous page. */ + pageToken?: string +} + +export interface OutlookCalendarListEventsResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookEvent[] + nextLink?: string + } +} + +export interface OutlookCalendarGetEventParams { + accessToken: string + eventId: string +} + +export interface OutlookCalendarGetEventResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookEvent + } +} + +export interface OutlookCalendarCreateEventParams { + accessToken: string + /** Calendar to create the event in. Omit for the mailbox's default calendar. */ + calendarId?: string + subject: string + startDateTime: string + endDateTime: string + timeZone?: string + body?: string + contentType?: 'text' | 'html' + location?: string + attendees?: string | string[] + isAllDay?: boolean + isOnlineMeeting?: boolean +} + +export interface OutlookCalendarCreateEventResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookEvent + } +} + +export interface OutlookCalendarUpdateEventParams { + accessToken: string + eventId: string + subject?: string + startDateTime?: string + endDateTime?: string + timeZone?: string + body?: string + contentType?: 'text' | 'html' + location?: string + attendees?: string | string[] + isAllDay?: boolean + isOnlineMeeting?: boolean +} + +export interface OutlookCalendarUpdateEventResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookEvent + } +} + +export interface OutlookCalendarDeleteEventParams { + accessToken: string + eventId: string +} + +export interface OutlookCalendarDeleteEventResponse extends ToolResponse { + output: { + message: string + results: { + eventId: string + status: string + } + } +} + +export type OutlookCalendarResponseType = 'accept' | 'tentativelyAccept' | 'decline' + +export interface OutlookCalendarRespondParams { + accessToken: string + eventId: string + responseType: OutlookCalendarResponseType + comment?: string + sendResponse?: boolean +} + +export interface OutlookCalendarRespondResponse extends ToolResponse { + output: { + message: string + results: { + eventId: string + responseType: OutlookCalendarResponseType + status: string + httpStatus?: number + requestId?: string + } + } +} + +export type OutlookCalendarResponse = + | OutlookCalendarListEventsResponse + | OutlookCalendarGetEventResponse + | OutlookCalendarCreateEventResponse + | OutlookCalendarUpdateEventResponse + | OutlookCalendarDeleteEventResponse + | OutlookCalendarRespondResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 9dc641b5cdb..1bf682cee65 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2716,6 +2716,12 @@ import { } from '@/tools/onepassword' import { openAIEmbeddingsTool, openAIImageTool } from '@/tools/openai' import { + outlookCalendarCreateEventTool, + outlookCalendarDeleteEventTool, + outlookCalendarGetEventTool, + outlookCalendarListEventsTool, + outlookCalendarRespondTool, + outlookCalendarUpdateEventTool, outlookCopyTool, outlookCreateFolderTool, outlookDeleteTool, @@ -7943,6 +7949,12 @@ export const tools: Record = { outlook_get_attachment: outlookGetAttachmentTool, outlook_search: outlookSearchTool, outlook_update_message: outlookUpdateMessageTool, + outlook_calendar_list_events: outlookCalendarListEventsTool, + outlook_calendar_get_event: outlookCalendarGetEventTool, + outlook_calendar_create_event: outlookCalendarCreateEventTool, + outlook_calendar_update_event: outlookCalendarUpdateEventTool, + outlook_calendar_delete_event: outlookCalendarDeleteEventTool, + outlook_calendar_respond: outlookCalendarRespondTool, pagerduty_list_incidents: pagerdutyListIncidentsTool, pagerduty_get_incident: pagerdutyGetIncidentTool, pagerduty_create_incident: pagerdutyCreateIncidentTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index db4fde94994..d872f1ee9a8 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 990, - zodRoutes: 990, + totalRoutes: 991, + zodRoutes: 991, nonZodRoutes: 0, } as const