Skip to content

Commit f6e6efb

Browse files
committed
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).
1 parent fe89723 commit f6e6efb

9 files changed

Lines changed: 123 additions & 24 deletions

File tree

apps/sim/blocks/blocks/outlook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
1212
description: 'Send, read, search, reply, organize, and manage Outlook email and calendar',
1313
authMode: AuthMode.OAuth,
1414
longDescription:
15-
'Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can also list, create, update, delete, and respond to calendar events and suggest meeting times. Can be used in trigger mode to trigger a workflow when a new email is received.',
15+
'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.',
1616
docsLink: 'https://docs.sim.ai/integrations/outlook',
1717
category: 'tools',
1818
integrationType: IntegrationType.Email,

apps/sim/lib/oauth/oauth.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
397397
icon: OutlookIcon,
398398
baseProviderIcon: MicrosoftIcon,
399399
/**
400-
* Calendars.ReadWrite and MailboxSettings.Read were added to support the Outlook
401-
* calendar operations. Microsoft only grants newly-added scopes on a fresh
402-
* authorization, so users who connected Outlook before these scopes existed must
403-
* reconnect (re-consent) their account before the calendar operations will work.
400+
* Calendars.ReadWrite was added to support the Outlook calendar operations.
401+
* Microsoft only grants newly-added scopes on a fresh authorization, so users who
402+
* connected Outlook before this scope existed must reconnect (re-consent) their
403+
* account before the calendar operations will work.
404404
*/
405405
scopes: [
406406
'openid',
@@ -411,7 +411,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
411411
'Mail.Read',
412412
'Mail.Send',
413413
'Calendars.ReadWrite',
414-
'MailboxSettings.Read',
415414
'offline_access',
416415
],
417416
},

apps/sim/lib/oauth/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,6 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
238238
'Mail.Read': 'Read Microsoft emails',
239239
'Mail.Send': 'Send emails',
240240
'Calendars.ReadWrite': 'Read and manage Outlook calendar events',
241-
'MailboxSettings.Read': 'Read mailbox settings (timezone, working hours)',
242241
'Files.Read': 'Read OneDrive files',
243242
'Files.ReadWrite': 'Read and write OneDrive files',
244243
'Tasks.ReadWrite': 'Read and manage Planner tasks',

apps/sim/tools/outlook/calendar-tools.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,35 @@ describe('outlook calendar tools', () => {
5252
expect(built).toContain('%24top=5')
5353
expect(built).toContain('%24orderby=start%2FdateTime')
5454

55-
// A nextLink page token is used verbatim.
55+
// A Graph-origin nextLink page token is accepted.
5656
expect(url({ accessToken: 't', pageToken: 'https://graph.microsoft.com/next' })).toBe(
5757
'https://graph.microsoft.com/next'
5858
)
5959
})
6060

61+
it('rejects a non-Graph pageToken so the bearer token cannot be exfiltrated', () => {
62+
const url = outlookCalendarListEventsTool.request.url as (p: unknown) => string
63+
expect(() => url({ accessToken: 't', pageToken: 'https://evil.example.com/steal' })).toThrow(
64+
/Microsoft Graph/
65+
)
66+
})
67+
68+
it('builds an all-day create body with midnight bounds and an exclusive end day', () => {
69+
const body = outlookCalendarCreateEventTool.request.body as (
70+
p: unknown
71+
) => Record<string, unknown>
72+
const built = body({
73+
accessToken: 't',
74+
subject: 'Offsite',
75+
startDateTime: '2025-06-03',
76+
endDateTime: '2025-06-03',
77+
isAllDay: true,
78+
})
79+
expect(built.isAllDay).toBe(true)
80+
expect(built.start).toEqual({ dateTime: '2025-06-03T00:00:00', timeZone: 'UTC' })
81+
expect(built.end).toEqual({ dateTime: '2025-06-04T00:00:00', timeZone: 'UTC' })
82+
})
83+
6184
it('builds a create-event body with Graph datetime shape and attendees', () => {
6285
const body = outlookCalendarCreateEventTool.request.body as (
6386
p: unknown

apps/sim/tools/outlook/calendar-utils.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
buildAllDayRange,
67
buildGraphEventDateTime,
78
DEFAULT_OUTLOOK_TIME_ZONE,
89
flattenGraphEvent,
@@ -48,6 +49,26 @@ describe('buildGraphEventDateTime', () => {
4849
})
4950
})
5051

52+
describe('buildAllDayRange', () => {
53+
it('advances a same-day end to the next day (exclusive end) at midnight', () => {
54+
expect(buildAllDayRange('2025-06-03', '2025-06-03', 'America/Los_Angeles')).toEqual({
55+
start: { dateTime: '2025-06-03T00:00:00', timeZone: 'America/Los_Angeles' },
56+
end: { dateTime: '2025-06-04T00:00:00', timeZone: 'America/Los_Angeles' },
57+
})
58+
})
59+
60+
it('preserves an already-multi-day range and strips any time component', () => {
61+
expect(buildAllDayRange('2025-06-03T09:30:00', '2025-06-05T14:00:00')).toEqual({
62+
start: { dateTime: '2025-06-03T00:00:00', timeZone: DEFAULT_OUTLOOK_TIME_ZONE },
63+
end: { dateTime: '2025-06-05T00:00:00', timeZone: DEFAULT_OUTLOOK_TIME_ZONE },
64+
})
65+
})
66+
67+
it('advances across a month boundary', () => {
68+
expect(buildAllDayRange('2025-06-30', '2025-06-30').end.dateTime).toBe('2025-07-01T00:00:00')
69+
})
70+
})
71+
5172
describe('normalizeAttendees', () => {
5273
it('returns an empty array for undefined', () => {
5374
expect(normalizeAttendees(undefined)).toEqual([])

apps/sim/tools/outlook/calendar-utils.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,46 @@ export function buildGraphEventDateTime(value: string, timeZone?: string): Graph
7676
return { dateTime: trimmed, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE }
7777
}
7878

79+
/** Extract the `YYYY-MM-DD` date portion from a date or datetime string. */
80+
function toDateOnly(value: string): string {
81+
const trimmed = value.trim()
82+
const tIndex = trimmed.indexOf('T')
83+
return tIndex === -1 ? trimmed : trimmed.slice(0, tIndex)
84+
}
85+
86+
/** Add one calendar day to a `YYYY-MM-DD` string. */
87+
function nextDay(dateOnly: string): string {
88+
const d = new Date(`${dateOnly}T00:00:00Z`)
89+
d.setUTCDate(d.getUTCDate() + 1)
90+
return d.toISOString().slice(0, 10)
91+
}
92+
93+
/**
94+
* Build the Graph `start`/`end` pair for an all-day event.
95+
*
96+
* Graph requires all-day events to have midnight `dateTime` values and an **exclusive**
97+
* end day strictly after the start day. Callers routinely pass the same date (or timed
98+
* placeholders) for both bounds, which Graph rejects with a 400. This normalizes both
99+
* bounds to midnight and advances the end to at least the day after the start.
100+
*/
101+
export function buildAllDayRange(
102+
startInput: string,
103+
endInput: string,
104+
timeZone?: string
105+
): { start: GraphDateTimeTimeZone; end: GraphDateTimeTimeZone } {
106+
const tz = timeZone || DEFAULT_OUTLOOK_TIME_ZONE
107+
const startDate = toDateOnly(startInput)
108+
let endDate = toDateOnly(endInput)
109+
// `YYYY-MM-DD` strings compare correctly lexicographically.
110+
if (endDate <= startDate) {
111+
endDate = nextDay(startDate)
112+
}
113+
return {
114+
start: { dateTime: `${startDate}T00:00:00`, timeZone: tz },
115+
end: { dateTime: `${endDate}T00:00:00`, timeZone: tz },
116+
}
117+
}
118+
79119
/**
80120
* Normalize a comma/newline-separated string (or array) of email addresses into the Graph
81121
* attendee payload shape.

apps/sim/tools/outlook/calendar_create_event.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ErrorExtractorId } from '@/tools/error-extractors'
22
import {
3+
buildAllDayRange,
34
buildGraphEventDateTime,
45
CALENDAR_RETRY,
56
flattenGraphEvent,
@@ -119,10 +120,18 @@ export const outlookCalendarCreateEventTool: ToolConfig<
119120
}
120121
},
121122
body: (params) => {
122-
const event: Record<string, unknown> = {
123-
subject: params.subject,
124-
start: buildGraphEventDateTime(params.startDateTime, params.timeZone),
125-
end: buildGraphEventDateTime(params.endDateTime, params.timeZone),
123+
const isAllDay = toBool(params.isAllDay)
124+
const event: Record<string, unknown> = { subject: params.subject }
125+
126+
if (isAllDay) {
127+
// All-day events need midnight bounds with an exclusive end day.
128+
const range = buildAllDayRange(params.startDateTime, params.endDateTime, params.timeZone)
129+
event.isAllDay = true
130+
event.start = range.start
131+
event.end = range.end
132+
} else {
133+
event.start = buildGraphEventDateTime(params.startDateTime, params.timeZone)
134+
event.end = buildGraphEventDateTime(params.endDateTime, params.timeZone)
126135
}
127136

128137
if (params.body) {
@@ -138,10 +147,6 @@ export const outlookCalendarCreateEventTool: ToolConfig<
138147
event.attendees = attendees
139148
}
140149

141-
if (toBool(params.isAllDay)) {
142-
event.isAllDay = true
143-
}
144-
145150
if (toBool(params.isOnlineMeeting)) {
146151
// Let Graph use the mailbox's default online-meeting provider (Teams on
147152
// work/school accounts) rather than hardcoding teamsForBusiness. Personal

apps/sim/tools/outlook/calendar_list_events.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
OutlookCalendarListEventsResponse,
77
} from '@/tools/outlook/types'
88
import { OUTLOOK_EVENT_OUTPUT_PROPERTIES } from '@/tools/outlook/types'
9+
import { assertGraphNextPageUrl } from '@/tools/sharepoint/utils'
910
import type { ToolConfig } from '@/tools/types'
1011

1112
const GRAPH_CALENDAR_VIEW_URL = 'https://graph.microsoft.com/v1.0/me/calendarView'
@@ -69,9 +70,11 @@ export const outlookCalendarListEventsTool: ToolConfig<
6970

7071
request: {
7172
url: (params) => {
72-
// A nextLink is a fully-formed Graph URL; use it verbatim to continue paging.
73+
// A nextLink is a fully-formed Graph URL. Validate the origin before reusing it
74+
// as the request URL — otherwise a workflow-supplied token would receive the
75+
// Outlook bearer. Mirrors the onedrive / microsoft_ad Graph paging guard.
7376
if (params.pageToken) {
74-
return params.pageToken
77+
return assertGraphNextPageUrl(params.pageToken.trim())
7578
}
7679

7780
const maxResults = params.maxResults

apps/sim/tools/outlook/calendar_update_event.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ErrorExtractorId } from '@/tools/error-extractors'
22
import {
3+
buildAllDayRange,
34
buildGraphEventDateTime,
45
CALENDAR_RETRY,
56
flattenGraphEvent,
@@ -126,17 +127,25 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
126127
body: (params) => {
127128
// PATCH is a partial update: only include fields the caller actually provided.
128129
const event: Record<string, unknown> = {}
130+
const settingAllDay = params.isAllDay !== undefined && toBool(params.isAllDay)
129131

130132
if (params.subject !== undefined) {
131133
event.subject = params.subject
132134
}
133135

134-
if (params.startDateTime) {
135-
event.start = buildGraphEventDateTime(params.startDateTime, params.timeZone)
136-
}
137-
138-
if (params.endDateTime) {
139-
event.end = buildGraphEventDateTime(params.endDateTime, params.timeZone)
136+
if (settingAllDay && params.startDateTime && params.endDateTime) {
137+
// Converting to all-day needs midnight bounds with an exclusive end day; send
138+
// both together so Graph doesn't reject a zero-length all-day window.
139+
const range = buildAllDayRange(params.startDateTime, params.endDateTime, params.timeZone)
140+
event.start = range.start
141+
event.end = range.end
142+
} else {
143+
if (params.startDateTime) {
144+
event.start = buildGraphEventDateTime(params.startDateTime, params.timeZone)
145+
}
146+
if (params.endDateTime) {
147+
event.end = buildGraphEventDateTime(params.endDateTime, params.timeZone)
148+
}
140149
}
141150

142151
if (params.body !== undefined) {

0 commit comments

Comments
 (0)