From 103703e4fd12132234042033e00eac17f92b6090 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 28 Jul 2026 19:49:56 -0700 Subject: [PATCH 01/14] feat(outlook): add Calendars.ReadWrite and MailboxSettings.Read scopes Extend the shared outlook OAuth service with delegated Graph calendar scopes so one Microsoft connection covers mail and calendar. Existing connected users must reconnect to be granted the new scopes (noted in a code comment). Adds human-readable scope descriptions and test assertions. --- apps/sim/lib/oauth/oauth.ts | 10 +++++++++- apps/sim/lib/oauth/utils.test.ts | 2 ++ apps/sim/lib/oauth/utils.ts | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 747b607a7f1..1f742cba55c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -392,10 +392,16 @@ 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 and MailboxSettings.Read were added to support the Outlook + * calendar operations. Microsoft only grants newly-added scopes on a fresh + * authorization, so users who connected Outlook before these scopes existed must + * reconnect (re-consent) their account before the calendar operations will work. + */ scopes: [ 'openid', 'profile', @@ -404,6 +410,8 @@ export const OAUTH_PROVIDERS: Record = { 'Mail.ReadBasic', 'Mail.Read', 'Mail.Send', + 'Calendars.ReadWrite', + 'MailboxSettings.Read', 'offline_access', ], }, diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index f087b6948f7..e85f4ed495b 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -335,6 +335,7 @@ describe('getCanonicalScopesForProvider', () => { expect(outlookScopes.length).toBeGreaterThan(0) expect(outlookScopes).toContain('Mail.ReadWrite') + expect(outlookScopes).toContain('Calendars.ReadWrite') const excelScopes = getCanonicalScopesForProvider('microsoft-excel') @@ -618,6 +619,7 @@ describe('getScopesForService', () => { expect(scopes.length).toBeGreaterThan(0) expect(scopes).toContain('Mail.ReadWrite') + expect(scopes).toContain('Calendars.ReadWrite') }) 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..f38e95e1523 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -237,6 +237,8 @@ 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', + 'MailboxSettings.Read': 'Read mailbox settings (timezone, working hours)', 'Files.Read': 'Read OneDrive files', 'Files.ReadWrite': 'Read and write OneDrive files', 'Tasks.ReadWrite': 'Read and manage Planner tasks', From 9eefc697b5218dac1411e23b1597f86d6a91bf2f Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 28 Jul 2026 19:49:58 -0700 Subject: [PATCH 02/14] feat(outlook): add Microsoft Graph calendar tools Six calendar operations against graph.microsoft.com/v1.0: list events (calendarView with nextLink paging), get, create, update (partial PATCH), delete, and respond to invites. Shared calendar-utils handles Graph's offset-less dateTime+timeZone shape, attendee normalization, and event flattening. All tools carry the Microsoft Graph error extractor and 429/backoff retry (honors Retry-After) for the mailbox concurrency limit. Registered in the tool registry and barrel. --- apps/sim/tools/outlook/calendar-tools.test.ts | 119 +++++++ apps/sim/tools/outlook/calendar-utils.test.ts | 120 +++++++ apps/sim/tools/outlook/calendar-utils.ts | 133 ++++++++ .../tools/outlook/calendar_create_event.ts | 177 ++++++++++ .../tools/outlook/calendar_delete_event.ts | 86 +++++ apps/sim/tools/outlook/calendar_get_event.ts | 78 +++++ .../sim/tools/outlook/calendar_list_events.ts | 132 ++++++++ apps/sim/tools/outlook/calendar_respond.ts | 153 +++++++++ .../tools/outlook/calendar_update_event.ts | 189 +++++++++++ apps/sim/tools/outlook/index.ts | 12 + apps/sim/tools/outlook/types.ts | 302 ++++++++++++++++++ apps/sim/tools/registry.ts | 12 + 12 files changed, 1513 insertions(+) create mode 100644 apps/sim/tools/outlook/calendar-tools.test.ts create mode 100644 apps/sim/tools/outlook/calendar-utils.test.ts create mode 100644 apps/sim/tools/outlook/calendar-utils.ts create mode 100644 apps/sim/tools/outlook/calendar_create_event.ts create mode 100644 apps/sim/tools/outlook/calendar_delete_event.ts create mode 100644 apps/sim/tools/outlook/calendar_get_event.ts create mode 100644 apps/sim/tools/outlook/calendar_list_events.ts create mode 100644 apps/sim/tools/outlook/calendar_respond.ts create mode 100644 apps/sim/tools/outlook/calendar_update_event.ts 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..31e22aae149 --- /dev/null +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -0,0 +1,119 @@ +/** + * @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 nextLink page token is used verbatim. + expect(url({ accessToken: 't', pageToken: 'https://graph.microsoft.com/next' })).toBe( + 'https://graph.microsoft.com/next' + ) + }) + + 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) + // We intentionally do NOT set onlineMeetingProvider so Graph uses the mailbox + // default (Teams on work/school accounts) instead of hardcoding a work-only value. + 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('omits the comment key when empty so sendResponse=false is accepted by Graph', () => { + 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 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('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..20f74863c93 --- /dev/null +++ b/apps/sim/tools/outlook/calendar-utils.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildGraphEventDateTime, + DEFAULT_OUTLOOK_TIME_ZONE, + flattenGraphEvent, + normalizeAttendees, +} from '@/tools/outlook/calendar-utils' +import type { GraphEvent } from '@/tools/outlook/types' + +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('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..3d050ef5fe4 --- /dev/null +++ b/apps/sim/tools/outlook/calendar-utils.ts @@ -0,0 +1,133 @@ +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). The tool executor retries 429/5xx with backoff + * and honors `Retry-After`. `retryIdempotentOnly` is `false` so create/update/respond + * (POST/PATCH) also retry — a 429 is a pre-processing throttle, so retrying does not + * duplicate the event. + */ +export const CALENDAR_RETRY: ToolRetryConfig = { + enabled: true, + maxRetries: 3, + initialDelayMs: 500, + maxDelayMs: 30000, + retryIdempotentOnly: false, +} + +/** 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 { + const trimmed = value.trim() + + 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 } +} + +/** + * 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..fab9e8fa5e0 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -0,0 +1,177 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { + buildGraphEventDateTime, + CALENDAR_RETRY, + flattenGraphEvent, + 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', + }, + 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) or a date (2025-06-03) for 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) or a date (2025-06-04) for 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. Uses the mailbox default provider (Teams on work/school accounts); personal accounts have no supported online-meeting provider.', + }, + }, + + request: { + url: 'https://graph.microsoft.com/v1.0/me/events', + 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) => { + const event: Record = { + subject: params.subject, + start: buildGraphEventDateTime(params.startDateTime, params.timeZone), + 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.isAllDay)) { + event.isAllDay = true + } + + if (toBool(params.isOnlineMeeting)) { + // Let Graph use the mailbox's default online-meeting provider (Teams on + // work/school accounts) rather than hardcoding teamsForBusiness. Personal + // accounts have no supported provider (Skype consumer was retired in 2025), + // so joinUrl stays null there regardless. + 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..1657b8dabba --- /dev/null +++ b/apps/sim/tools/outlook/calendar_delete_event.ts @@ -0,0 +1,86 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { 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) => + `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}`, + 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..822e0fbeafa --- /dev/null +++ b/apps/sim/tools/outlook/calendar_get_event.ts @@ -0,0 +1,78 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { 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) => + `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}`, + 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..5801b6afb8e --- /dev/null +++ b/apps/sim/tools/outlook/calendar_list_events.ts @@ -0,0 +1,132 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { 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 type { ToolConfig } from '@/tools/types' + +const GRAPH_CALENDAR_VIEW_URL = 'https://graph.microsoft.com/v1.0/me/calendarView' + +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', + }, + startDateTime: { + type: 'string', + required: true, + 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.', + }, + endDateTime: { + type: 'string', + required: true, + 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.', + }, + 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; use it verbatim to continue paging. + if (params.pageToken) { + return params.pageToken + } + + const maxResults = params.maxResults + ? Math.max(1, Math.min(Math.abs(Number(params.maxResults)), 100)) + : 10 + + const queryParams = new URLSearchParams() + queryParams.append('startDateTime', params.startDateTime) + queryParams.append('endDateTime', params.endDateTime) + queryParams.append('$top', String(maxResults)) + queryParams.append('$orderby', params.orderBy || 'start/dateTime') + + return `${GRAPH_CALENDAR_VIEW_URL}?${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..a72bee00731 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_respond.ts @@ -0,0 +1,153 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { 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 `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}/${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) => { + // Graph rejects sendResponse=false when a `comment` key is present at all + // (even ""): "'SendResponse' must be true when 'Comment' is not null." So + // only include comment when it's actually non-empty. + 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..7e8cce35345 --- /dev/null +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -0,0 +1,189 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import { + buildGraphEventDateTime, + CALENDAR_RETRY, + flattenGraphEvent, + 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) or a date (2025-06-03) for an all-day event', + }, + endDateTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New end time (ISO 8601) or a date (2025-06-04) for an all-day event', + }, + 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', + }, + isOnlineMeeting: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Attach an online meeting to the event. Uses the mailbox default provider (Teams on work/school accounts); personal accounts have no supported online-meeting provider.', + }, + }, + + request: { + url: (params) => + `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}`, + 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 = {} + + if (params.subject !== undefined) { + event.subject = params.subject + } + + 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 (params.isAllDay !== undefined) { + event.isAllDay = toBool(params.isAllDay) + } + + if (params.isOnlineMeeting !== undefined) { + // Let Graph use the mailbox's default online-meeting provider (Teams on + // work/school accounts) rather than hardcoding teamsForBusiness. Personal + // accounts have no supported provider (Skype consumer was retired in 2025). + 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..da6ee0aa317 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,304 @@ 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 + startDateTime: string + 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 + 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, From fe897239d961ebd08fcdbdc0595a4bf3581521ed Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 28 Jul 2026 19:49:58 -0700 Subject: [PATCH 03/14] feat(outlook): surface calendar operations in the Outlook block Add six calendar operations to the Outlook block operation dropdown with their conditional subBlocks, tool wiring, inputs, and event-shaped outputs. Mail operations are unchanged and backward compatible. --- apps/sim/blocks/blocks/outlook.ts | 352 +++++++++++++++++++++++++++++- 1 file changed, 350 insertions(+), 2 deletions(-) diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 5c75331f9da..2bf1474c3dc 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 and suggest meeting times. 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,226 @@ export const OutlookBlock: BlockConfig = { mode: 'advanced', required: false, }, + // 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, + }, + { + 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, + }, + // 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 URL from a previous response', + 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, + }, + { + 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, + }, + // 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, + }, + { + 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, + }, + // 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, + }, + { + 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, + }, + { + id: 'calSendResponse', + title: 'Send Response to Organizer', + type: 'switch', + condition: { field: 'operation', value: 'respond_calendar' }, + mode: 'advanced', + }, ...getTrigger('outlook_poller').subBlocks, ], tools: { @@ -409,6 +635,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 +679,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 +711,25 @@ export const OutlookBlock: BlockConfig = { includeHiddenFolders, categories, maxResults, + calEventId, + calWindowStart, + calWindowEnd, + calMaxResults, + calOrderBy, + calPageToken, + calSubject, + calStartDateTime, + calEndDateTime, + calBody, + calContentType, + calLocation, + calAttendees, + calTimeZone, + calIsAllDay, + calIsOnlineMeeting, + calResponseType, + calComment, + calSendResponse, ...rest } = params @@ -555,6 +818,60 @@ export const OutlookBlock: BlockConfig = { } } + const isSet = (value: unknown): boolean => + value !== undefined && value !== null && value !== '' + + 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 + if (isSet(calSendResponse)) rest.sendResponse = toBool(calSendResponse) + } + return { ...rest, oauthCredential, @@ -600,6 +917,35 @@ 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 + 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: 'boolean', description: 'Whether to notify the organizer' }, }, outputs: { // Common outputs @@ -635,6 +981,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' }, From f6e6efbeb2dc8a54230c1856041ddc51fd5db36f Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 28 Jul 2026 20:03:06 -0700 Subject: [PATCH 04/14] fix(outlook): address calendar review findings - Validate list-events pageToken origin with assertGraphNextPageUrl (matches the onedrive/microsoft_ad Graph paging guard) so a workflow-supplied URL can't receive the Outlook bearer token. - All-day create/update now normalize both bounds to midnight and force an exclusive end day (buildAllDayRange), instead of sending a zero-length same-midnight window that Graph rejects. - Drop the stale 'suggest meeting times' claim from the block longDescription. - Remove the unused MailboxSettings.Read scope (least privilege; no tool reads it). --- apps/sim/blocks/blocks/outlook.ts | 2 +- apps/sim/lib/oauth/oauth.ts | 9 ++--- apps/sim/lib/oauth/utils.ts | 1 - apps/sim/tools/outlook/calendar-tools.test.ts | 25 +++++++++++- apps/sim/tools/outlook/calendar-utils.test.ts | 21 ++++++++++ apps/sim/tools/outlook/calendar-utils.ts | 40 +++++++++++++++++++ .../tools/outlook/calendar_create_event.ts | 21 ++++++---- .../sim/tools/outlook/calendar_list_events.ts | 7 +++- .../tools/outlook/calendar_update_event.ts | 21 +++++++--- 9 files changed, 123 insertions(+), 24 deletions(-) diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 2bf1474c3dc..65ab929a16e 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -12,7 +12,7 @@ export const OutlookBlock: BlockConfig = { 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 also list, create, update, delete, and respond to calendar events and suggest meeting times. 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, diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 1f742cba55c..5f0b0f353e8 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -397,10 +397,10 @@ export const OAUTH_PROVIDERS: Record = { icon: OutlookIcon, baseProviderIcon: MicrosoftIcon, /** - * Calendars.ReadWrite and MailboxSettings.Read were added to support the Outlook - * calendar operations. Microsoft only grants newly-added scopes on a fresh - * authorization, so users who connected Outlook before these scopes existed must - * reconnect (re-consent) their account before the calendar operations will work. + * Calendars.ReadWrite was added to support the Outlook calendar operations. + * Microsoft only grants newly-added scopes on a fresh authorization, so users who + * connected Outlook before this scope existed must reconnect (re-consent) their + * account before the calendar operations will work. */ scopes: [ 'openid', @@ -411,7 +411,6 @@ export const OAUTH_PROVIDERS: Record = { 'Mail.Read', 'Mail.Send', 'Calendars.ReadWrite', - 'MailboxSettings.Read', 'offline_access', ], }, diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index f38e95e1523..ce4e754d727 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -238,7 +238,6 @@ export const SCOPE_DESCRIPTIONS: Record = { 'Mail.Read': 'Read Microsoft emails', 'Mail.Send': 'Send emails', 'Calendars.ReadWrite': 'Read and manage Outlook calendar events', - 'MailboxSettings.Read': 'Read mailbox settings (timezone, working hours)', '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 index 31e22aae149..602d1535b47 100644 --- a/apps/sim/tools/outlook/calendar-tools.test.ts +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -52,12 +52,35 @@ describe('outlook calendar tools', () => { expect(built).toContain('%24top=5') expect(built).toContain('%24orderby=start%2FdateTime') - // A nextLink page token is used verbatim. + // 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('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 diff --git a/apps/sim/tools/outlook/calendar-utils.test.ts b/apps/sim/tools/outlook/calendar-utils.test.ts index 20f74863c93..07e1365f06e 100644 --- a/apps/sim/tools/outlook/calendar-utils.test.ts +++ b/apps/sim/tools/outlook/calendar-utils.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + buildAllDayRange, buildGraphEventDateTime, DEFAULT_OUTLOOK_TIME_ZONE, flattenGraphEvent, @@ -48,6 +49,26 @@ describe('buildGraphEventDateTime', () => { }) }) +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([]) diff --git a/apps/sim/tools/outlook/calendar-utils.ts b/apps/sim/tools/outlook/calendar-utils.ts index 3d050ef5fe4..944378ed92c 100644 --- a/apps/sim/tools/outlook/calendar-utils.ts +++ b/apps/sim/tools/outlook/calendar-utils.ts @@ -76,6 +76,46 @@ export function buildGraphEventDateTime(value: string, timeZone?: string): Graph return { dateTime: trimmed, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE } } +/** 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. diff --git a/apps/sim/tools/outlook/calendar_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts index fab9e8fa5e0..9cbd7ef9ecc 100644 --- a/apps/sim/tools/outlook/calendar_create_event.ts +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -1,5 +1,6 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import { + buildAllDayRange, buildGraphEventDateTime, CALENDAR_RETRY, flattenGraphEvent, @@ -119,10 +120,18 @@ export const outlookCalendarCreateEventTool: ToolConfig< } }, body: (params) => { - const event: Record = { - subject: params.subject, - start: buildGraphEventDateTime(params.startDateTime, params.timeZone), - end: buildGraphEventDateTime(params.endDateTime, params.timeZone), + const isAllDay = toBool(params.isAllDay) + const event: Record = { subject: params.subject } + + 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) { @@ -138,10 +147,6 @@ export const outlookCalendarCreateEventTool: ToolConfig< event.attendees = attendees } - if (toBool(params.isAllDay)) { - event.isAllDay = true - } - if (toBool(params.isOnlineMeeting)) { // Let Graph use the mailbox's default online-meeting provider (Teams on // work/school accounts) rather than hardcoding teamsForBusiness. Personal diff --git a/apps/sim/tools/outlook/calendar_list_events.ts b/apps/sim/tools/outlook/calendar_list_events.ts index 5801b6afb8e..98db2aab49a 100644 --- a/apps/sim/tools/outlook/calendar_list_events.ts +++ b/apps/sim/tools/outlook/calendar_list_events.ts @@ -6,6 +6,7 @@ import type { 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' const GRAPH_CALENDAR_VIEW_URL = 'https://graph.microsoft.com/v1.0/me/calendarView' @@ -69,9 +70,11 @@ export const outlookCalendarListEventsTool: ToolConfig< request: { url: (params) => { - // A nextLink is a fully-formed Graph URL; use it verbatim to continue paging. + // 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 params.pageToken + return assertGraphNextPageUrl(params.pageToken.trim()) } const maxResults = params.maxResults diff --git a/apps/sim/tools/outlook/calendar_update_event.ts b/apps/sim/tools/outlook/calendar_update_event.ts index 7e8cce35345..c8945a9e893 100644 --- a/apps/sim/tools/outlook/calendar_update_event.ts +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -1,5 +1,6 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import { + buildAllDayRange, buildGraphEventDateTime, CALENDAR_RETRY, flattenGraphEvent, @@ -126,17 +127,25 @@ export const outlookCalendarUpdateEventTool: ToolConfig< body: (params) => { // PATCH is a partial update: only include fields the caller actually provided. const event: Record = {} + const settingAllDay = params.isAllDay !== undefined && toBool(params.isAllDay) if (params.subject !== undefined) { event.subject = params.subject } - if (params.startDateTime) { - event.start = buildGraphEventDateTime(params.startDateTime, params.timeZone) - } - - if (params.endDateTime) { - event.end = buildGraphEventDateTime(params.endDateTime, params.timeZone) + if (settingAllDay && params.startDateTime && params.endDateTime) { + // Converting to all-day needs midnight bounds with an exclusive end day; send + // both together so Graph doesn't reject a zero-length all-day window. + const range = buildAllDayRange(params.startDateTime, params.endDateTime, 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) { From 0f9b94656e89d6d0ef99a67653bede1261f4f2f6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:06:02 -0700 Subject: [PATCH 05/14] feat(outlook): add calendar picker and harden calendar tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation pass over the new Microsoft Graph calendar operations against the v1.0 API docs, plus the calendar selection the tools were missing. - Add an `outlook.calendars` picker (GET /me/calendars) with basic selector + advanced manual ID, wired through the `calendarId` canonical param. List and create now target `/me/calendars/{id}/...`; get/update/delete/respond keep using `/me/events/{id}` since event IDs are mailbox-unique. - Fix all-day update rejecting when only one bound is supplied — both bounds are now normalized to midnight with an exclusive end day. - Fix "Send Response to Organizer" reading as OFF while Graph's default is to notify; it is now a dropdown defaulting to Yes, and the param is always sent. - Add timestamp wandConfig to the four calendar datetime fields and a list wandConfig to attendees. - Centralize Graph URL construction in calendar-utils; guard maxResults against non-numeric input and trim the calendarView time-window bounds. - Add three calendar templates and four calendar skills to OutlookBlockMeta. - Regenerate integration docs. --- .../content/docs/en/integrations/outlook.mdx | 230 +++++++++++++++++- .../app/api/tools/outlook/calendars/route.ts | 163 +++++++++++++ apps/sim/blocks/blocks/outlook.ts | 206 +++++++++++++++- .../providers/microsoft/selectors.ts | 23 ++ apps/sim/hooks/selectors/types.ts | 1 + apps/sim/lib/api/contracts/selectors/index.ts | 2 + .../lib/api/contracts/selectors/microsoft.ts | 8 + apps/sim/lib/integrations/integrations.json | 32 ++- apps/sim/tools/outlook/calendar-tools.test.ts | 38 +++ apps/sim/tools/outlook/calendar-utils.ts | 29 +++ .../tools/outlook/calendar_create_event.ts | 9 +- .../tools/outlook/calendar_delete_event.ts | 5 +- apps/sim/tools/outlook/calendar_get_event.ts | 5 +- .../sim/tools/outlook/calendar_list_events.ts | 26 +- apps/sim/tools/outlook/calendar_respond.ts | 4 +- .../tools/outlook/calendar_update_event.ts | 16 +- apps/sim/tools/outlook/types.ts | 4 + scripts/check-api-validation-contracts.ts | 4 +- 18 files changed, 771 insertions(+), 34 deletions(-) create mode 100644 apps/sim/app/api/tools/outlook/calendars/route.ts diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index f7341289fe2..6aa868fdb9c 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. | +| `startDateTime` | string | Yes | Start of the time window \(ISO 8601, e.g. 2025-06-03T00:00:00-08:00\). Interpreted as UTC if no offset is given. | +| `endDateTime` | string | Yes | End of the time window \(ISO 8601, e.g. 2025-06-10T00:00:00-08:00\). Interpreted as UTC if no offset is given. | +| `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 default calendar. | +| `subject` | string | Yes | Event subject/title | +| `startDateTime` | string | Yes | Start time \(ISO 8601, e.g. 2025-06-03T10:00:00-08:00\) or a date \(2025-06-03\) for an all-day event | +| `endDateTime` | string | Yes | End time \(ISO 8601, e.g. 2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for 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. Uses the mailbox default provider \(Teams on work/school accounts\); personal accounts have no supported online-meeting provider. | + +#### 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\) or a date \(2025-06-03\) for an all-day event | +| `endDateTime` | string | No | New end time \(ISO 8601\) or a date \(2025-06-04\) for an all-day event | +| `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 | +| `isOnlineMeeting` | boolean | No | Attach an online meeting to the event. Uses the mailbox default provider \(Teams on work/school accounts\); personal accounts have no supported online-meeting provider. | + +#### 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.ts b/apps/sim/blocks/blocks/outlook.ts index 65ab929a16e..a8f4f7b71c8 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -394,6 +394,38 @@ 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', @@ -419,6 +451,19 @@ export const OutlookBlock: BlockConfig = { 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', @@ -427,6 +472,19 @@ export const OutlookBlock: BlockConfig = { 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 { @@ -468,6 +526,19 @@ export const OutlookBlock: BlockConfig = { 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', @@ -476,6 +547,18 @@ export const OutlookBlock: BlockConfig = { 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) { @@ -493,6 +576,18 @@ export const OutlookBlock: BlockConfig = { 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', @@ -501,6 +596,18 @@ export const OutlookBlock: BlockConfig = { 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 { @@ -552,6 +659,15 @@ export const OutlookBlock: BlockConfig = { 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', @@ -607,12 +723,20 @@ export const OutlookBlock: BlockConfig = { 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: 'switch', + 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, ], @@ -711,6 +835,7 @@ export const OutlookBlock: BlockConfig = { includeHiddenFolders, categories, maxResults, + calendarId, calEventId, calWindowStart, calWindowEnd, @@ -821,6 +946,15 @@ 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() @@ -869,7 +1003,8 @@ export const OutlookBlock: BlockConfig = { if (rest.operation === 'respond_calendar') { if (calResponseType) rest.responseType = calResponseType if (isSet(calComment)) rest.comment = calComment - if (isSet(calSendResponse)) rest.sendResponse = toBool(calSendResponse) + // Notifying the organizer is the default; an unset dropdown must not read as "no". + rest.sendResponse = isSet(calSendResponse) ? toBool(calSendResponse) : true } return { @@ -918,6 +1053,10 @@ export const OutlookBlock: BlockConfig = { 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)' }, @@ -945,12 +1084,19 @@ export const OutlookBlock: BlockConfig = { description: 'Invite response (accept, tentativelyAccept, or decline)', }, calComment: { type: 'string', description: 'Comment to send with an invite response' }, - calSendResponse: { type: 'boolean', description: 'Whether to notify the organizer' }, + 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' }, @@ -1073,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: [ { @@ -1099,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/tools/outlook/calendar-tools.test.ts b/apps/sim/tools/outlook/calendar-tools.test.ts index 602d1535b47..3870a2810d2 100644 --- a/apps/sim/tools/outlook/calendar-tools.test.ts +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -58,6 +58,44 @@ describe('outlook calendar tools', () => { ) }) + 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('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( diff --git a/apps/sim/tools/outlook/calendar-utils.ts b/apps/sim/tools/outlook/calendar-utils.ts index 944378ed92c..c93ce4b964a 100644 --- a/apps/sim/tools/outlook/calendar-utils.ts +++ b/apps/sim/tools/outlook/calendar-utils.ts @@ -25,6 +25,35 @@ export const CALENDAR_RETRY: ToolRetryConfig = { 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)$/ diff --git a/apps/sim/tools/outlook/calendar_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts index 9cbd7ef9ecc..f92df8232d0 100644 --- a/apps/sim/tools/outlook/calendar_create_event.ts +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -1,6 +1,7 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import { buildAllDayRange, + buildCalendarScopedUrl, buildGraphEventDateTime, CALENDAR_RETRY, flattenGraphEvent, @@ -40,6 +41,12 @@ export const outlookCalendarCreateEventTool: ToolConfig< 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 default calendar.', + }, subject: { type: 'string', required: true, @@ -107,7 +114,7 @@ export const outlookCalendarCreateEventTool: ToolConfig< }, request: { - url: 'https://graph.microsoft.com/v1.0/me/events', + url: (params) => buildCalendarScopedUrl('events', params.calendarId), method: 'POST', retry: CALENDAR_RETRY, headers: (params) => { diff --git a/apps/sim/tools/outlook/calendar_delete_event.ts b/apps/sim/tools/outlook/calendar_delete_event.ts index 1657b8dabba..5b68c42d294 100644 --- a/apps/sim/tools/outlook/calendar_delete_event.ts +++ b/apps/sim/tools/outlook/calendar_delete_event.ts @@ -1,5 +1,5 @@ import { ErrorExtractorId } from '@/tools/error-extractors' -import { CALENDAR_RETRY } from '@/tools/outlook/calendar-utils' +import { buildEventUrl, CALENDAR_RETRY } from '@/tools/outlook/calendar-utils' import type { OutlookCalendarDeleteEventParams, OutlookCalendarDeleteEventResponse, @@ -38,8 +38,7 @@ export const outlookCalendarDeleteEventTool: ToolConfig< }, request: { - url: (params) => - `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}`, + url: (params) => buildEventUrl(params.eventId), method: 'DELETE', retry: CALENDAR_RETRY, headers: (params) => { diff --git a/apps/sim/tools/outlook/calendar_get_event.ts b/apps/sim/tools/outlook/calendar_get_event.ts index 822e0fbeafa..15d2dcfd905 100644 --- a/apps/sim/tools/outlook/calendar_get_event.ts +++ b/apps/sim/tools/outlook/calendar_get_event.ts @@ -1,5 +1,5 @@ import { ErrorExtractorId } from '@/tools/error-extractors' -import { CALENDAR_RETRY, flattenGraphEvent } from '@/tools/outlook/calendar-utils' +import { buildEventUrl, CALENDAR_RETRY, flattenGraphEvent } from '@/tools/outlook/calendar-utils' import type { GraphEvent, OutlookCalendarGetEventParams, @@ -40,8 +40,7 @@ export const outlookCalendarGetEventTool: ToolConfig< }, request: { - url: (params) => - `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}`, + url: (params) => buildEventUrl(params.eventId), method: 'GET', retry: CALENDAR_RETRY, headers: (params) => { diff --git a/apps/sim/tools/outlook/calendar_list_events.ts b/apps/sim/tools/outlook/calendar_list_events.ts index 98db2aab49a..2b6e4aeee13 100644 --- a/apps/sim/tools/outlook/calendar_list_events.ts +++ b/apps/sim/tools/outlook/calendar_list_events.ts @@ -1,5 +1,9 @@ import { ErrorExtractorId } from '@/tools/error-extractors' -import { CALENDAR_RETRY, flattenGraphEvent } from '@/tools/outlook/calendar-utils' +import { + buildCalendarScopedUrl, + CALENDAR_RETRY, + flattenGraphEvent, +} from '@/tools/outlook/calendar-utils' import type { GraphEventsResponse, OutlookCalendarListEventsParams, @@ -9,7 +13,8 @@ import { OUTLOOK_EVENT_OUTPUT_PROPERTIES } from '@/tools/outlook/types' import { assertGraphNextPageUrl } from '@/tools/sharepoint/utils' import type { ToolConfig } from '@/tools/types' -const GRAPH_CALENDAR_VIEW_URL = 'https://graph.microsoft.com/v1.0/me/calendarView' +/** 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, @@ -34,6 +39,12 @@ export const outlookCalendarListEventsTool: ToolConfig< 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.', + }, startDateTime: { type: 'string', required: true, @@ -77,17 +88,18 @@ export const outlookCalendarListEventsTool: ToolConfig< return assertGraphNextPageUrl(params.pageToken.trim()) } - const maxResults = params.maxResults - ? Math.max(1, Math.min(Math.abs(Number(params.maxResults)), 100)) + 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) - queryParams.append('endDateTime', params.endDateTime) + 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 `${GRAPH_CALENDAR_VIEW_URL}?${queryParams.toString()}` + return `${buildCalendarScopedUrl('calendarView', params.calendarId)}?${queryParams.toString()}` }, method: 'GET', retry: CALENDAR_RETRY, diff --git a/apps/sim/tools/outlook/calendar_respond.ts b/apps/sim/tools/outlook/calendar_respond.ts index a72bee00731..0f02dea8aac 100644 --- a/apps/sim/tools/outlook/calendar_respond.ts +++ b/apps/sim/tools/outlook/calendar_respond.ts @@ -1,5 +1,5 @@ import { ErrorExtractorId } from '@/tools/error-extractors' -import { CALENDAR_RETRY } from '@/tools/outlook/calendar-utils' +import { buildEventUrl, CALENDAR_RETRY } from '@/tools/outlook/calendar-utils' import type { OutlookCalendarRespondParams, OutlookCalendarRespondResponse, @@ -76,7 +76,7 @@ export const outlookCalendarRespondTool: ToolConfig< `Invalid responseType "${params.responseType}". Expected one of: ${VALID_RESPONSE_TYPES.join(', ')}` ) } - return `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}/${responseType}` + return buildEventUrl(params.eventId, responseType) }, method: 'POST', retry: CALENDAR_RETRY, diff --git a/apps/sim/tools/outlook/calendar_update_event.ts b/apps/sim/tools/outlook/calendar_update_event.ts index c8945a9e893..7306b6c550e 100644 --- a/apps/sim/tools/outlook/calendar_update_event.ts +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -1,6 +1,7 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import { buildAllDayRange, + buildEventUrl, buildGraphEventDateTime, CALENDAR_RETRY, flattenGraphEvent, @@ -111,8 +112,7 @@ export const outlookCalendarUpdateEventTool: ToolConfig< }, request: { - url: (params) => - `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(params.eventId.trim())}`, + url: (params) => buildEventUrl(params.eventId), method: 'PATCH', retry: CALENDAR_RETRY, headers: (params) => { @@ -133,10 +133,14 @@ export const outlookCalendarUpdateEventTool: ToolConfig< event.subject = params.subject } - if (settingAllDay && params.startDateTime && params.endDateTime) { - // Converting to all-day needs midnight bounds with an exclusive end day; send - // both together so Graph doesn't reject a zero-length all-day window. - const range = buildAllDayRange(params.startDateTime, params.endDateTime, params.timeZone) + if (settingAllDay && (params.startDateTime || params.endDateTime)) { + // Converting to all-day needs midnight bounds with an exclusive end day. Graph + // rejects the whole PATCH if either bound is timed or the window is zero-length, + // so normalize both together — falling back to the supplied bound when the caller + // only gave one, which `buildAllDayRange` then advances to the next day. + 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 { diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index da6ee0aa317..30bd8641735 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -708,6 +708,8 @@ export interface GraphEventsResponse { export interface OutlookCalendarListEventsParams { accessToken: string + /** Calendar to read. Omit for the mailbox's default calendar. */ + calendarId?: string startDateTime: string endDateTime: string maxResults?: number @@ -738,6 +740,8 @@ export interface OutlookCalendarGetEventResponse extends ToolResponse { 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 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 From 4abde7eaca151cfdbb93a4452316a0c16da09c36 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:12:33 -0700 Subject: [PATCH 06/14] fix(outlook): scope online-meeting comments to what Graph documents onlineMeetingProvider is optional and defaults to unknown; the docs state that setting isOnlineMeeting alone initializes onlineMeeting. They do not document Graph substituting the calendar's defaultOnlineMeetingProvider, so the comments now claim only that, plus the real reason not to pin teamsForBusiness (mailboxes that disallow it via allowedOnlineMeetingProviders). --- apps/sim/tools/outlook/calendar-tools.test.ts | 4 ++-- apps/sim/tools/outlook/calendar_create_event.ts | 9 +++++---- apps/sim/tools/outlook/calendar_update_event.ts | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/sim/tools/outlook/calendar-tools.test.ts b/apps/sim/tools/outlook/calendar-tools.test.ts index 3870a2810d2..a5032592ca0 100644 --- a/apps/sim/tools/outlook/calendar-tools.test.ts +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -135,8 +135,8 @@ describe('outlook calendar tools', () => { expect(built.start).toEqual({ dateTime: '2025-06-03T18:00:00', timeZone: 'UTC' }) expect(built.attendees).toHaveLength(2) expect(built.isOnlineMeeting).toBe(true) - // We intentionally do NOT set onlineMeetingProvider so Graph uses the mailbox - // default (Teams on work/school accounts) instead of hardcoding a work-only value. + // 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) }) diff --git a/apps/sim/tools/outlook/calendar_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts index f92df8232d0..1826e1e452e 100644 --- a/apps/sim/tools/outlook/calendar_create_event.ts +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -155,10 +155,11 @@ export const outlookCalendarCreateEventTool: ToolConfig< } if (toBool(params.isOnlineMeeting)) { - // Let Graph use the mailbox's default online-meeting provider (Teams on - // work/school accounts) rather than hardcoding teamsForBusiness. Personal - // accounts have no supported provider (Skype consumer was retired in 2025), - // so joinUrl stays null there regardless. + // 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 } diff --git a/apps/sim/tools/outlook/calendar_update_event.ts b/apps/sim/tools/outlook/calendar_update_event.ts index 7306b6c550e..752373a0661 100644 --- a/apps/sim/tools/outlook/calendar_update_event.ts +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -169,9 +169,9 @@ export const outlookCalendarUpdateEventTool: ToolConfig< } if (params.isOnlineMeeting !== undefined) { - // Let Graph use the mailbox's default online-meeting provider (Teams on - // work/school accounts) rather than hardcoding teamsForBusiness. Personal - // accounts have no supported provider (Skype consumer was retired in 2025). + // 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) } From 801f44297701c7af1169d732ecd7daa4ccf35208 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:16:08 -0700 Subject: [PATCH 07/14] fix(outlook): allow calendar paging without re-supplying the time window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot round: startDateTime/endDateTime were required tool params, so a paging call carrying only pageToken failed validateToolParameters before the request was built — even though the url builder short-circuits on pageToken and ignores both bounds. Relax them to optional and enforce the real invariant (pageToken OR both bounds) in the url builder, matching tools/sharepoint/list_sites.ts. Block subblocks stay required, so the normal editor flow is unchanged. Also correct the calendar_respond comment: Graph documents exactly two 400 conditions for accept/decline, both on proposedNewTime, which we never send. A non-empty comment alongside sendResponse=false is valid. --- apps/sim/tools/outlook/calendar-tools.test.ts | 20 ++++++++++++++++++- .../sim/tools/outlook/calendar_list_events.ts | 17 ++++++++++++---- apps/sim/tools/outlook/calendar_respond.ts | 8 +++++--- apps/sim/tools/outlook/types.ts | 6 ++++-- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/sim/tools/outlook/calendar-tools.test.ts b/apps/sim/tools/outlook/calendar-tools.test.ts index a5032592ca0..733ebc3570f 100644 --- a/apps/sim/tools/outlook/calendar-tools.test.ts +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -150,7 +150,19 @@ describe('outlook calendar tools', () => { ) }) - it('omits the comment key when empty so sendResponse=false is accepted by Graph', () => { + 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({ @@ -165,6 +177,12 @@ describe('outlook calendar tools', () => { 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' }) diff --git a/apps/sim/tools/outlook/calendar_list_events.ts b/apps/sim/tools/outlook/calendar_list_events.ts index 2b6e4aeee13..a9f980b43c3 100644 --- a/apps/sim/tools/outlook/calendar_list_events.ts +++ b/apps/sim/tools/outlook/calendar_list_events.ts @@ -45,19 +45,22 @@ export const outlookCalendarListEventsTool: ToolConfig< visibility: 'user-only', description: 'ID of the calendar to read. Defaults to the mailbox default calendar.', }, + // 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: true, + 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.', + '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: true, + 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.', + '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', @@ -88,6 +91,12 @@ export const outlookCalendarListEventsTool: ToolConfig< 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)) diff --git a/apps/sim/tools/outlook/calendar_respond.ts b/apps/sim/tools/outlook/calendar_respond.ts index 0f02dea8aac..687e1068eb8 100644 --- a/apps/sim/tools/outlook/calendar_respond.ts +++ b/apps/sim/tools/outlook/calendar_respond.ts @@ -90,9 +90,11 @@ export const outlookCalendarRespondTool: ToolConfig< } }, body: (params) => { - // Graph rejects sendResponse=false when a `comment` key is present at all - // (even ""): "'SendResponse' must be true when 'Comment' is not null." So - // only include comment when it's actually non-empty. + // 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), } diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index 30bd8641735..4e1360b8f9f 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -710,8 +710,10 @@ export interface OutlookCalendarListEventsParams { accessToken: string /** Calendar to read. Omit for the mailbox's default calendar. */ calendarId?: string - startDateTime: string - endDateTime: 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. */ From a0730fc2e2b11fb9f3991e0d1786a72ffebcbc84 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:20:36 -0700 Subject: [PATCH 08/14] feat(outlook): add Calendars.ReadWrite.Shared for shared calendars The calendar picker lists /me/calendars, which can include calendars other users have shared with or delegated to the account. Calendars.ReadWrite covers only the user's own calendars, so selecting a shared team calendar would 403 on both read and write. Calendars.ReadWrite is kept alongside it, not replaced: Graph documents it as the sole accepted permission for creating and updating events and for accept/tentativelyAccept/decline (all list "Higher: Not available"), so the .Shared scope does not subsume it. Added now rather than later because this PR already forces existing Outlook users to re-consent for Calendars.ReadWrite; deferring would cost them a second reconnect. --- apps/sim/lib/oauth/oauth.ts | 16 ++++++++++++++-- apps/sim/lib/oauth/utils.test.ts | 2 ++ apps/sim/lib/oauth/utils.ts | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 5f0b0f353e8..5d8b80d1a2f 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -397,10 +397,21 @@ export const OAUTH_PROVIDERS: Record = { icon: OutlookIcon, baseProviderIcon: MicrosoftIcon, /** - * Calendars.ReadWrite was added to support the Outlook calendar operations. + * Calendar scopes back the Outlook calendar operations. Both are required, and + * neither subsumes the other: + * - `Calendars.ReadWrite` is the only permission Graph accepts for creating and + * updating events and for accept / tentativelyAccept / decline, which document + * it as least-privileged with "Higher: Not available". + * - `Calendars.ReadWrite.Shared` additionally covers calendars other users have + * shared with (or delegated to) the account. The calendar picker lists + * `/me/calendars`, which can include those, so without it selecting a shared + * team calendar would 403 on read and write. + * * Microsoft only grants newly-added scopes on a fresh authorization, so users who - * connected Outlook before this scope existed must reconnect (re-consent) their + * connected Outlook before these scopes existed must reconnect (re-consent) their * account before the calendar operations will work. + * + * @see https://learn.microsoft.com/en-us/graph/permissions-reference */ scopes: [ 'openid', @@ -411,6 +422,7 @@ export const OAUTH_PROVIDERS: Record = { 'Mail.Read', 'Mail.Send', 'Calendars.ReadWrite', + 'Calendars.ReadWrite.Shared', 'offline_access', ], }, diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index e85f4ed495b..c251d4f500e 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -336,6 +336,7 @@ describe('getCanonicalScopesForProvider', () => { expect(outlookScopes.length).toBeGreaterThan(0) expect(outlookScopes).toContain('Mail.ReadWrite') expect(outlookScopes).toContain('Calendars.ReadWrite') + expect(outlookScopes).toContain('Calendars.ReadWrite.Shared') const excelScopes = getCanonicalScopesForProvider('microsoft-excel') @@ -620,6 +621,7 @@ describe('getScopesForService', () => { expect(scopes.length).toBeGreaterThan(0) expect(scopes).toContain('Mail.ReadWrite') expect(scopes).toContain('Calendars.ReadWrite') + expect(scopes).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 ce4e754d727..ec1ae74e9ef 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -238,6 +238,7 @@ export const SCOPE_DESCRIPTIONS: Record = { 'Mail.Read': 'Read Microsoft emails', 'Mail.Send': 'Send emails', 'Calendars.ReadWrite': 'Read and manage Outlook calendar events', + 'Calendars.ReadWrite.Shared': 'Read and manage shared Outlook calendars', 'Files.Read': 'Read OneDrive files', 'Files.ReadWrite': 'Read and write OneDrive files', 'Tasks.ReadWrite': 'Read and manage Planner tasks', From 6ade310284b89da06ebbc00fa49ddd13359d1753 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:34:32 -0700 Subject: [PATCH 09/14] fix(outlook): treat date-only bounds as all-day and guard partial all-day updates Cursor round 2: - Date-only bounds no longer produce a zero-length window. The param docs invited a date like 2025-06-03 for an all-day event, but buildAllDayRange only ran when isAllDay was explicitly true, so date-only input built a 00:00->00:00 timed window that Graph rejects. A date-only bound carries no time, so the only coherent reading is all-day; create/update now promote on that shape and the descriptions state it. - Converting an event to all-day with no bounds now fails with an actionable message instead of a Graph 400. Graph requires all-day events to have midnight start and end in the same zone, and those cannot be derived from a partial PATCH against an event whose existing bounds are timed. --- .../content/docs/en/integrations/outlook.mdx | 14 +++--- apps/sim/tools/outlook/calendar-tools.test.ts | 50 +++++++++++++++++++ apps/sim/tools/outlook/calendar-utils.ts | 11 ++++ .../tools/outlook/calendar_create_event.ts | 12 +++-- .../tools/outlook/calendar_update_event.ts | 39 +++++++++++---- 5 files changed, 106 insertions(+), 20 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index 6aa868fdb9c..bccf717ce8c 100644 --- a/apps/docs/content/docs/en/integrations/outlook.mdx +++ b/apps/docs/content/docs/en/integrations/outlook.mdx @@ -482,8 +482,8 @@ List Outlook calendar events within a start/end time window | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `calendarId` | string | No | ID of the calendar to read. Defaults to the mailbox default calendar. | -| `startDateTime` | string | Yes | Start of the time window \(ISO 8601, e.g. 2025-06-03T00:00:00-08:00\). Interpreted as UTC if no offset is given. | -| `endDateTime` | string | Yes | End of the time window \(ISO 8601, e.g. 2025-06-10T00:00:00-08:00\). Interpreted as UTC if no offset is given. | +| `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 | @@ -567,8 +567,8 @@ Create a new Outlook calendar event | --------- | ---- | -------- | ----------- | | `calendarId` | string | No | ID of the calendar to create the event in. Defaults to the default calendar. | | `subject` | string | Yes | Event subject/title | -| `startDateTime` | string | Yes | Start time \(ISO 8601, e.g. 2025-06-03T10:00:00-08:00\) or a date \(2025-06-03\) for an all-day event | -| `endDateTime` | string | Yes | End time \(ISO 8601, e.g. 2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for an all-day event | +| `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\) | @@ -616,14 +616,14 @@ Update an existing Outlook calendar event | --------- | ---- | -------- | ----------- | | `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\) or a date \(2025-06-03\) for an all-day event | -| `endDateTime` | string | No | New end time \(ISO 8601\) or a date \(2025-06-04\) for an all-day event | +| `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 | +| `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. Uses the mailbox default provider \(Teams on work/school accounts\); personal accounts have no supported online-meeting provider. | #### Output diff --git a/apps/sim/tools/outlook/calendar-tools.test.ts b/apps/sim/tools/outlook/calendar-tools.test.ts index 733ebc3570f..cc82a0bddda 100644 --- a/apps/sim/tools/outlook/calendar-tools.test.ts +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -80,6 +80,56 @@ describe('outlook calendar tools', () => { ) }) + 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() + }) + + 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 diff --git a/apps/sim/tools/outlook/calendar-utils.ts b/apps/sim/tools/outlook/calendar-utils.ts index c93ce4b964a..4243a148c7d 100644 --- a/apps/sim/tools/outlook/calendar-utils.ts +++ b/apps/sim/tools/outlook/calendar-utils.ts @@ -105,6 +105,17 @@ export function buildGraphEventDateTime(value: string, timeZone?: string): Graph return { dateTime: trimmed, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE } } +/** + * 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. + */ +export function isDateOnly(value: string | undefined): boolean { + return Boolean(value) && !value!.includes('T') +} + /** Extract the `YYYY-MM-DD` date portion from a date or datetime string. */ function toDateOnly(value: string): string { const trimmed = value.trim() diff --git a/apps/sim/tools/outlook/calendar_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts index 1826e1e452e..ed11185a499 100644 --- a/apps/sim/tools/outlook/calendar_create_event.ts +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -5,6 +5,7 @@ import { buildGraphEventDateTime, CALENDAR_RETRY, flattenGraphEvent, + isDateOnly, normalizeAttendees, } from '@/tools/outlook/calendar-utils' import type { @@ -58,14 +59,14 @@ export const outlookCalendarCreateEventTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Start time (ISO 8601, e.g. 2025-06-03T10:00:00-08:00) or a date (2025-06-03) for an all-day event', + '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) or a date (2025-06-04) for an all-day event', + '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', @@ -127,7 +128,12 @@ export const outlookCalendarCreateEventTool: ToolConfig< } }, body: (params) => { - const isAllDay = toBool(params.isAllDay) + // 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 } if (isAllDay) { diff --git a/apps/sim/tools/outlook/calendar_update_event.ts b/apps/sim/tools/outlook/calendar_update_event.ts index 752373a0661..8381c50156a 100644 --- a/apps/sim/tools/outlook/calendar_update_event.ts +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -5,6 +5,7 @@ import { buildGraphEventDateTime, CALENDAR_RETRY, flattenGraphEvent, + isDateOnly, normalizeAttendees, } from '@/tools/outlook/calendar-utils' import type { @@ -57,13 +58,15 @@ export const outlookCalendarUpdateEventTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'New start time (ISO 8601) or a date (2025-06-03) for an all-day event', + 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) or a date (2025-06-04) for an all-day event', + description: + 'New end time (ISO 8601). A date-only value (2025-06-04) converts the event to all-day.', }, timeZone: { type: 'string', @@ -100,7 +103,8 @@ export const outlookCalendarUpdateEventTool: ToolConfig< type: 'boolean', required: false, visibility: 'user-or-llm', - description: 'Whether the event lasts the entire day', + description: + 'Whether the event lasts the entire day. Setting this true requires also sending startDateTime, since Graph needs midnight bounds.', }, isOnlineMeeting: { type: 'boolean', @@ -127,17 +131,30 @@ export const outlookCalendarUpdateEventTool: ToolConfig< body: (params) => { // PATCH is a partial update: only include fields the caller actually provided. const event: Record = {} - const settingAllDay = params.isAllDay !== undefined && toBool(params.isAllDay) + + // Date-only bounds carry no time, so they can only mean an all-day event — promote + // them even when the caller didn't set the flag (see calendar_create_event). + const providedBounds = [params.startDateTime, params.endDateTime].filter(Boolean) + const allProvidedBoundsDateOnly = + providedBounds.length > 0 && providedBounds.every((bound) => isDateOnly(bound)) + const settingAllDay = + (params.isAllDay !== undefined && toBool(params.isAllDay)) || allProvidedBoundsDateOnly if (params.subject !== undefined) { event.subject = params.subject } - if (settingAllDay && (params.startDateTime || params.endDateTime)) { - // Converting to all-day needs midnight bounds with an exclusive end day. Graph - // rejects the whole PATCH if either bound is timed or the window is zero-length, - // so normalize both together — falling back to the supplied bound when the caller - // only gave one, which `buildAllDayRange` then advances to the next day. + 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 — falling back to the supplied one when only a + // single bound was given, which `buildAllDayRange` then advances to the next day. const start = params.startDateTime || params.endDateTime! const end = params.endDateTime || params.startDateTime! const range = buildAllDayRange(start, end, params.timeZone) @@ -164,7 +181,9 @@ export const outlookCalendarUpdateEventTool: ToolConfig< event.attendees = normalizeAttendees(params.attendees) } - if (params.isAllDay !== undefined) { + if (settingAllDay) { + event.isAllDay = true + } else if (params.isAllDay !== undefined) { event.isAllDay = toBool(params.isAllDay) } From 170ddb8fba0651e82b36230f06de495c491022c9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:36:54 -0700 Subject: [PATCH 10/14] docs(outlook): note that the calendar window fields are ignored when paging --- apps/sim/blocks/blocks/outlook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index a8f4f7b71c8..e9ec3bb9e66 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -506,7 +506,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'calPageToken', title: 'Page Token', type: 'short-input', - placeholder: 'nextLink URL from a previous response', + placeholder: 'nextLink from a previous response (window fields are then ignored)', condition: { field: 'operation', value: 'list_events_calendar' }, mode: 'advanced', }, From a42782d81223eef65ef6705b7400a4ce684787c7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 21:55:45 -0700 Subject: [PATCH 11/14] fix(outlook): don't promote a lone date-only bound to all-day on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the previous round's date-only promotion. A single date-only startDateTime or endDateTime satisfied "all provided bounds are date-only", so the tool promoted to all-day and derived the missing side from the supplied one — turning a partial reschedule of a timed or multi-day event into a one-day all-day event and dropping the original other bound. Implicit promotion now requires BOTH bounds to be date-only, matching calendar_create_event. A lone date-only bound is ambiguous (convert to all-day, or just move that edge?) and a PATCH cannot read the event's existing bounds to disambiguate, so it stays on the timed path and leaves the other side untouched. Deriving a missing bound remains allowed when isAllDay is set explicitly, since that is stated intent rather than a guess. Also pins the explicit-isAllDay-false + date-only override with a regression test: the block always sends isAllDay for create, so an untouched switch arrives as false, and the data shape has to win or the fix would be unreachable from the UI. --- apps/sim/tools/outlook/calendar-tools.test.ts | 65 +++++++++++++++++++ .../tools/outlook/calendar_update_event.ts | 20 +++--- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/sim/tools/outlook/calendar-tools.test.ts b/apps/sim/tools/outlook/calendar-tools.test.ts index cc82a0bddda..25cccb30c3c 100644 --- a/apps/sim/tools/outlook/calendar-tools.test.ts +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -116,6 +116,71 @@ describe('outlook calendar tools', () => { 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', () => { diff --git a/apps/sim/tools/outlook/calendar_update_event.ts b/apps/sim/tools/outlook/calendar_update_event.ts index 8381c50156a..14b864414f4 100644 --- a/apps/sim/tools/outlook/calendar_update_event.ts +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -132,13 +132,16 @@ export const outlookCalendarUpdateEventTool: ToolConfig< // PATCH is a partial update: only include fields the caller actually provided. const event: Record = {} - // Date-only bounds carry no time, so they can only mean an all-day event — promote - // them even when the caller didn't set the flag (see calendar_create_event). const providedBounds = [params.startDateTime, params.endDateTime].filter(Boolean) - const allProvidedBoundsDateOnly = - providedBounds.length > 0 && providedBounds.every((bound) => isDateOnly(bound)) - const settingAllDay = - (params.isAllDay !== undefined && toBool(params.isAllDay)) || allProvidedBoundsDateOnly + 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 @@ -153,8 +156,9 @@ export const outlookCalendarUpdateEventTool: ToolConfig< '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 — falling back to the supplied one when only a - // single bound was given, which `buildAllDayRange` then advances to the next day. + // 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) From 6b6b65522d7749ba0526958b98cd34c2daa4272a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 22:17:41 -0700 Subject: [PATCH 12/14] revert(outlook): drop Calendars.ReadWrite.Shared from the outlook provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the scope I added two rounds ago. It was the wrong call. This provider is shared by work/school 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, breaking functionality that works today. The PR already documents this exact reasoning as why findMeetingTimes was excluded, and that decision was made against a live personal mailbox. The evidence I added it on was a summarized read of the permissions reference claiming MSA support; a targeted follow-up could not confirm it for Calendars.ReadWrite.Shared specifically. Given the asymmetry — broken consent for all personal users vs. a shared-calendar feature gap — least privilege wins. Calendar operations therefore target calendars the account owns. The calendarId param descriptions now say a calendar shared by another user may return 403, and the scope list carries a comment explaining why .Shared must not be re-added. --- .../content/docs/en/integrations/outlook.mdx | 4 +-- apps/sim/lib/oauth/oauth.ts | 26 ++++++++++--------- apps/sim/lib/oauth/utils.test.ts | 6 +++-- apps/sim/lib/oauth/utils.ts | 1 - .../tools/outlook/calendar_create_event.ts | 3 ++- .../sim/tools/outlook/calendar_list_events.ts | 3 ++- 6 files changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index bccf717ce8c..86c0c125634 100644 --- a/apps/docs/content/docs/en/integrations/outlook.mdx +++ b/apps/docs/content/docs/en/integrations/outlook.mdx @@ -481,7 +481,7 @@ List Outlook calendar events within a start/end time window | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `calendarId` | string | No | ID of the calendar to read. Defaults to the mailbox default calendar. | +| `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\) | @@ -565,7 +565,7 @@ Create a new Outlook calendar event | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `calendarId` | string | No | ID of the calendar to create the event in. Defaults to the default calendar. | +| `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. | diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 5d8b80d1a2f..fc3c6bc7064 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -397,19 +397,22 @@ export const OAUTH_PROVIDERS: Record = { icon: OutlookIcon, baseProviderIcon: MicrosoftIcon, /** - * Calendar scopes back the Outlook calendar operations. Both are required, and - * neither subsumes the other: - * - `Calendars.ReadWrite` is the only permission Graph accepts for creating and - * updating events and for accept / tentativelyAccept / decline, which document - * it as least-privileged with "Higher: Not available". - * - `Calendars.ReadWrite.Shared` additionally covers calendars other users have - * shared with (or delegated to) the account. The calendar picker lists - * `/me/calendars`, which can include those, so without it selecting a shared - * team calendar would 403 on read and write. + * `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 these scopes existed must reconnect (re-consent) their - * account before the calendar operations will work. + * 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 */ @@ -422,7 +425,6 @@ export const OAUTH_PROVIDERS: Record = { 'Mail.Read', 'Mail.Send', 'Calendars.ReadWrite', - 'Calendars.ReadWrite.Shared', 'offline_access', ], }, diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index c251d4f500e..acc0945270e 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -336,7 +336,9 @@ describe('getCanonicalScopesForProvider', () => { expect(outlookScopes.length).toBeGreaterThan(0) expect(outlookScopes).toContain('Mail.ReadWrite') expect(outlookScopes).toContain('Calendars.ReadWrite') - expect(outlookScopes).toContain('Calendars.ReadWrite.Shared') + // .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') @@ -621,7 +623,7 @@ describe('getScopesForService', () => { expect(scopes.length).toBeGreaterThan(0) expect(scopes).toContain('Mail.ReadWrite') expect(scopes).toContain('Calendars.ReadWrite') - expect(scopes).toContain('Calendars.ReadWrite.Shared') + 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 ec1ae74e9ef..ce4e754d727 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -238,7 +238,6 @@ export const SCOPE_DESCRIPTIONS: Record = { 'Mail.Read': 'Read Microsoft emails', 'Mail.Send': 'Send emails', 'Calendars.ReadWrite': 'Read and manage Outlook calendar events', - 'Calendars.ReadWrite.Shared': 'Read and manage shared Outlook calendars', '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_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts index ed11185a499..07725c510f9 100644 --- a/apps/sim/tools/outlook/calendar_create_event.ts +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -46,7 +46,8 @@ export const outlookCalendarCreateEventTool: ToolConfig< type: 'string', required: false, visibility: 'user-only', - description: 'ID of the calendar to create the event in. Defaults to the default calendar.', + 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', diff --git a/apps/sim/tools/outlook/calendar_list_events.ts b/apps/sim/tools/outlook/calendar_list_events.ts index a9f980b43c3..b73305b2071 100644 --- a/apps/sim/tools/outlook/calendar_list_events.ts +++ b/apps/sim/tools/outlook/calendar_list_events.ts @@ -43,7 +43,8 @@ export const outlookCalendarListEventsTool: ToolConfig< type: 'string', required: false, visibility: 'user-only', - description: 'ID of the calendar to read. Defaults to the mailbox default calendar.', + 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". From 2564def7443aa9cb4b3ae28faba2ceb8abb30684 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 22:29:26 -0700 Subject: [PATCH 13/14] fix(outlook): make retried event creates duplicate-safe via transactionId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry config opts POSTs in via retryIdempotentOnly:false, but the executor's isRetryableFailure covers 429 AND 500-599 — not just the throttle the comment justified. A 5xx returned after Graph had already committed a create would be retried and produce a duplicate calendar event. create_event now sends a transactionId, which Graph documents for exactly this: it discards a repeat POST carrying an id it has already seen. The request body is built once per execution (formatRequestParams runs before the attempt loop), so the id is stable across retries of a call and unique between calls. The retry comment now describes what actually retries and why each non-idempotent method is safe: PATCH replays the same partial body as a no-op, and respond is state-idempotent though a post-commit retry can send the organizer a second notification — accepted deliberately, since Graph exposes no transactionId for accept/decline and failing outright under throttling is worse. --- apps/sim/tools/outlook/calendar-tools.test.ts | 19 +++++++++++++++++++ apps/sim/tools/outlook/calendar-utils.ts | 18 ++++++++++++++---- .../tools/outlook/calendar_create_event.ts | 12 +++++++++++- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/sim/tools/outlook/calendar-tools.test.ts b/apps/sim/tools/outlook/calendar-tools.test.ts index 25cccb30c3c..57a00e20f91 100644 --- a/apps/sim/tools/outlook/calendar-tools.test.ts +++ b/apps/sim/tools/outlook/calendar-tools.test.ts @@ -303,6 +303,25 @@ describe('outlook calendar tools', () => { 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) diff --git a/apps/sim/tools/outlook/calendar-utils.ts b/apps/sim/tools/outlook/calendar-utils.ts index 4243a148c7d..f9832c7f212 100644 --- a/apps/sim/tools/outlook/calendar-utils.ts +++ b/apps/sim/tools/outlook/calendar-utils.ts @@ -12,10 +12,20 @@ import type { ToolRetryConfig } from '@/tools/types' * * 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). The tool executor retries 429/5xx with backoff - * and honors `Retry-After`. `retryIdempotentOnly` is `false` so create/update/respond - * (POST/PATCH) also retry — a 429 is a pre-processing throttle, so retrying does not - * duplicate the event. + * 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, diff --git a/apps/sim/tools/outlook/calendar_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts index 07725c510f9..ec58f12e28b 100644 --- a/apps/sim/tools/outlook/calendar_create_event.ts +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -1,3 +1,4 @@ +import { generateId } from '@sim/utils/id' import { ErrorExtractorId } from '@/tools/error-extractors' import { buildAllDayRange, @@ -135,7 +136,16 @@ export const outlookCalendarCreateEventTool: ToolConfig< // 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 } + 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. From bc55db2213c8aaf0a9d3a57602cf7cea78e2f791 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 10:04:10 -0700 Subject: [PATCH 14/14] fix(outlook): tighten date-only detection and align online-meeting copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final validation pass over the calendar integration. - isDateOnly matched "contains no T", so a space-separated datetime (2025-06-03 10:00) counted as date-only: the time was discarded and the value built as '2025-06-03 10:00T00:00:00', which Graph rejects. It now matches YYYY-MM-DD strictly, and buildGraphEventDateTime normalizes the space form to ISO rather than mangling it, so a natural input works instead of 400ing. - The isOnlineMeeting param descriptions still claimed Graph 'uses the mailbox default provider' — the same unverified mechanism already removed from the code comments. They now state only what the docs and the author's live testing support: the join URL depends on the providers the mailbox allows, and stays null on personal accounts. - Adds blocks/blocks/outlook.test.ts following the repo's per-block test convention: every calendar operation resolves to a registered tool in tools.access, supplies all required tool params, emits no params the tool cannot accept, maps one-to-one onto the calendar tools, and the calendarId canonical group and sendResponse default are pinned. --- .../content/docs/en/integrations/outlook.mdx | 4 +- apps/sim/blocks/blocks/outlook.test.ts | 120 ++++++++++++++++++ apps/sim/tools/outlook/calendar-utils.test.ts | 24 ++++ apps/sim/tools/outlook/calendar-utils.ts | 17 ++- .../tools/outlook/calendar_create_event.ts | 2 +- .../tools/outlook/calendar_update_event.ts | 2 +- 6 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 apps/sim/blocks/blocks/outlook.test.ts diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index 86c0c125634..5b6d701c120 100644 --- a/apps/docs/content/docs/en/integrations/outlook.mdx +++ b/apps/docs/content/docs/en/integrations/outlook.mdx @@ -575,7 +575,7 @@ Create a new Outlook calendar event | `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. Uses the mailbox default provider \(Teams on work/school accounts\); personal accounts have no supported online-meeting provider. | +| `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 @@ -624,7 +624,7 @@ Update an existing Outlook calendar event | `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. Uses the mailbox default provider \(Teams on work/school accounts\); personal accounts have no supported online-meeting provider. | +| `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 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/tools/outlook/calendar-utils.test.ts b/apps/sim/tools/outlook/calendar-utils.test.ts index 07e1365f06e..f7c9663c675 100644 --- a/apps/sim/tools/outlook/calendar-utils.test.ts +++ b/apps/sim/tools/outlook/calendar-utils.test.ts @@ -7,10 +7,34 @@ import { 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({ diff --git a/apps/sim/tools/outlook/calendar-utils.ts b/apps/sim/tools/outlook/calendar-utils.ts index f9832c7f212..1be0a1a3ff6 100644 --- a/apps/sim/tools/outlook/calendar-utils.ts +++ b/apps/sim/tools/outlook/calendar-utils.ts @@ -93,7 +93,10 @@ export interface GraphAttendeeInput { * - A naive datetime is treated as wall-clock time in the provided (or default) zone. */ export function buildGraphEventDateTime(value: string, timeZone?: string): GraphDateTimeTimeZone { - const trimmed = value.trim() + // 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 } @@ -115,15 +118,25 @@ export function buildGraphEventDateTime(value: string, timeZone?: string): Graph 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) && !value!.includes('T') + return Boolean(value) && DATE_ONLY_PATTERN.test(value!.trim()) } /** Extract the `YYYY-MM-DD` date portion from a date or datetime string. */ diff --git a/apps/sim/tools/outlook/calendar_create_event.ts b/apps/sim/tools/outlook/calendar_create_event.ts index ec58f12e28b..1180166d4ae 100644 --- a/apps/sim/tools/outlook/calendar_create_event.ts +++ b/apps/sim/tools/outlook/calendar_create_event.ts @@ -112,7 +112,7 @@ export const outlookCalendarCreateEventTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Attach an online meeting to the event. Uses the mailbox default provider (Teams on work/school accounts); personal accounts have no supported online-meeting provider.', + '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.', }, }, diff --git a/apps/sim/tools/outlook/calendar_update_event.ts b/apps/sim/tools/outlook/calendar_update_event.ts index 14b864414f4..c2806ec1d17 100644 --- a/apps/sim/tools/outlook/calendar_update_event.ts +++ b/apps/sim/tools/outlook/calendar_update_event.ts @@ -111,7 +111,7 @@ export const outlookCalendarUpdateEventTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Attach an online meeting to the event. Uses the mailbox default provider (Teams on work/school accounts); personal accounts have no supported online-meeting provider.', + '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.', }, },