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