diff --git a/integrations/outlook/.prettierrc b/integrations/outlook/.prettierrc new file mode 100644 index 00000000..83332558 --- /dev/null +++ b/integrations/outlook/.prettierrc @@ -0,0 +1,19 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "rangeStart": 0, + "rangeEnd": null, + "requirePragma": false, + "insertPragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "endOfLine": "lf" +} diff --git a/integrations/outlook/eslint.config.js b/integrations/outlook/eslint.config.js new file mode 100644 index 00000000..0f27a212 --- /dev/null +++ b/integrations/outlook/eslint.config.js @@ -0,0 +1,72 @@ +import js from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import importPlugin from 'eslint-plugin-import'; +import eslintPluginPrettier from 'eslint-plugin-prettier'; +import unusedImports from 'eslint-plugin-unused-imports'; +import typescriptEslint from 'typescript-eslint'; + +export default [ + js.configs.recommended, + ...typescriptEslint.configs.recommended, + eslintConfigPrettier, + { + files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + global: 'readonly', + }, + }, + plugins: { + import: importPlugin, + prettier: eslintPluginPrettier, + 'unused-imports': unusedImports, + }, + rules: { + 'prettier/prettier': 'error', + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + 'import/order': [ + 'error', + { + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', + ], + 'newlines-between': 'always', + }, + ], + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'warn', + }, + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, + }, +]; diff --git a/integrations/outlook/package.json b/integrations/outlook/package.json new file mode 100644 index 00000000..240799f8 --- /dev/null +++ b/integrations/outlook/package.json @@ -0,0 +1,60 @@ +{ + "name": "@core/outlook", + "version": "0.1.0", + "description": "Microsoft Outlook integration for Core", + "main": "./bin/index.js", + "module": "./bin/index.mjs", + "type": "module", + "files": [ + "outlook", + "bin" + ], + "bin": { + "outlook": "./bin/index.js" + }, + "scripts": { + "build": "rimraf dist && bun build src/index.ts --outfile dist/index.js --target node --minify", + "lint": "eslint --ext js,ts,tsx src/ --fix", + "prettier": "prettier --config .prettierrc --write ." + }, + "devDependencies": { + "@babel/preset-typescript": "^7.26.0", + "@types/node": "^18.0.20", + "@types/turndown": "^5.0.5", + "eslint": "^9.24.0", + "eslint-config-prettier": "^10.1.2", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-unused-imports": "^2.0.0", + "prettier": "^3.4.2", + "rimraf": "^3.0.2", + "tslib": "^2.8.1", + "typescript": "^4.7.2", + "@modelcontextprotocol/sdk": "^0.4.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.1" + }, + "publishConfig": { + "access": "public" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "dependencies": { + "axios": "^1.7.9", + "commander": "^12.0.0", + "@redplanethq/sdk": "0.1.9", + "turndown": "^7.2.0" + } +} diff --git a/integrations/outlook/src/account-create.ts b/integrations/outlook/src/account-create.ts new file mode 100644 index 00000000..43ac89b0 --- /dev/null +++ b/integrations/outlook/src/account-create.ts @@ -0,0 +1,55 @@ +import axios from 'axios'; + +export async function integrationCreate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any +) { + const { oauthResponse, oauthParams } = data; + + // Fetch user information from Microsoft Graph + let userEmail = null; + let userId = null; + let displayName = null; + + try { + const userInfoResponse = await axios.get('https://graph.microsoft.com/v1.0/me', { + headers: { + Authorization: `Bearer ${oauthResponse.access_token}`, + }, + }); + + userEmail = userInfoResponse.data.mail || userInfoResponse.data.userPrincipalName; + userId = userInfoResponse.data.id; + displayName = userInfoResponse.data.displayName; + } catch (error) { + console.error('Error fetching user info:', error); + } + + const integrationConfiguration = { + access_token: oauthResponse.access_token, + refresh_token: oauthResponse.refresh_token, + client_id: oauthResponse.client_id, + client_secret: oauthResponse.client_secret, + token_type: oauthResponse.token_type, + expires_in: oauthResponse.expires_in, + expires_at: oauthResponse.expires_at, + scope: oauthResponse.scope, + userEmail: userEmail, + userId: userId, + displayName: displayName, + redirect_uri: oauthParams.redirect_uri || null, + }; + + const payload = { + settings: {}, + accountId: integrationConfiguration.userEmail || integrationConfiguration.userId, + config: integrationConfiguration, + }; + + return [ + { + type: 'account', + data: payload, + }, + ]; +} diff --git a/integrations/outlook/src/index.ts b/integrations/outlook/src/index.ts new file mode 100644 index 00000000..5478ba83 --- /dev/null +++ b/integrations/outlook/src/index.ts @@ -0,0 +1,109 @@ +import { + IntegrationCLI, + IntegrationEventPayload, + IntegrationEventType, + Spec, +} from '@redplanethq/sdk'; + +import { integrationCreate } from './account-create'; +import { handleSchedule } from './schedule'; +import { getTools, callTool } from './mcp'; +import { fileURLToPath } from 'url'; + +export async function run(eventPayload: IntegrationEventPayload) { + switch (eventPayload.event) { + case IntegrationEventType.SETUP: + return await integrationCreate(eventPayload.eventBody); + + case IntegrationEventType.SYNC: + return await handleSchedule(eventPayload.config, eventPayload.state); + + case IntegrationEventType.GET_TOOLS: { + const tools = await getTools(); + + return tools; + } + + case IntegrationEventType.CALL_TOOL: { + const integrationDefinition = eventPayload.integrationDefinition; + + if (!integrationDefinition) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config = eventPayload.config as any; + const { name, arguments: args } = eventPayload.eventBody; + + const result = await callTool( + name, + args, + integrationDefinition.config.clientId, + integrationDefinition.config.clientSecret, + config?.redirect_uri, + config + ); + + return result; + } + + default: + return { message: `The event payload type is ${eventPayload.event}` }; + } +} + +class OutlookCLI extends IntegrationCLI { + constructor() { + super('outlook', '1.0.0'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected async handleEvent(eventPayload: IntegrationEventPayload): Promise { + return await run(eventPayload); + } + + protected async getSpec(): Promise { + return { + name: 'Outlook extension', + key: 'outlook', + description: + 'Connect your workspace to Microsoft Outlook. Monitor emails, manage calendar events, and handle contacts via Microsoft Graph API', + icon: 'outlook', + schedule: { + frequency: '*/15 * * * *', + }, + auth: { + OAuth2: { + token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + scopes: [ + 'openid', + 'profile', + 'email', + 'offline_access', + 'https://graph.microsoft.com/Mail.ReadWrite', + 'https://graph.microsoft.com/Mail.Send', + 'https://graph.microsoft.com/Calendars.ReadWrite', + 'https://graph.microsoft.com/Contacts.ReadWrite', + 'https://graph.microsoft.com/User.Read', + ], + scope_identifier: 'scope', + scope_separator: ' ', + token_params: {}, + authorization_params: { + response_type: 'code', + }, + }, + }, + } as any; + } +} + +function main() { + const outlookCLI = new OutlookCLI(); + outlookCLI.parse(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/integrations/outlook/src/mcp/generated-tools.ts b/integrations/outlook/src/mcp/generated-tools.ts new file mode 100644 index 00000000..c5d245df --- /dev/null +++ b/integrations/outlook/src/mcp/generated-tools.ts @@ -0,0 +1,14 @@ +// Auto-generated tool stubs for Microsoft Outlook (Graph API) +// Custom tools are defined in index.ts + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const generatedTools: any[] = []; + +export async function handleGeneratedTool( + name: string, + _args: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _client: any +): Promise<{ content: Array<{ type: string; text: string }> }> { + throw new Error(`Unknown generated tool: ${name}`); +} diff --git a/integrations/outlook/src/mcp/index.ts b/integrations/outlook/src/mcp/index.ts new file mode 100644 index 00000000..919c8768 --- /dev/null +++ b/integrations/outlook/src/mcp/index.ts @@ -0,0 +1,718 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import axios, { AxiosInstance } from 'axios'; + +import { generatedTools, handleGeneratedTool } from './generated-tools'; + +const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; + +let graphClient: AxiosInstance; + +async function loadCredentials( + clientId: string, + clientSecret: string, + _redirectUri: string, + config: Record +) { + let accessToken = config.access_token; + + // Try refreshing the token + try { + const response = await axios.post( + 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: config.refresh_token, + grant_type: 'refresh_token', + scope: 'https://graph.microsoft.com/.default offline_access', + }), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ); + accessToken = response.data.access_token; + } catch { + // Use existing token if refresh fails + } + + graphClient = axios.create({ + baseURL: GRAPH_BASE_URL, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); +} + +// ─── Schema Definitions ─── + +const SearchEmailsSchema = z.object({ + query: z.string().describe('Search query for emails (uses Microsoft Search syntax)'), + top: z.number().optional().default(25).describe('Number of results to return (max 50)'), + folder: z + .string() + .optional() + .describe('Folder to search in (e.g. inbox, sentitems, drafts). Defaults to all folders'), +}); + +const ReadEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email message to read'), +}); + +const SendEmailSchema = z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject'), + body: z.string().describe('Email body content (supports HTML)'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + bcc: z.array(z.string()).optional().describe('Array of BCC email addresses'), + isHtml: z.boolean().optional().default(true).describe('Whether the body content is HTML'), +}); + +const ReplyToEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to reply to'), + body: z.string().describe('Reply body content (supports HTML)'), + replyAll: z.boolean().optional().default(false).describe('Whether to reply all'), +}); + +const ForwardEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to forward'), + to: z.array(z.string()).describe('Array of recipient email addresses'), + comment: z.string().optional().describe('Optional comment to include with forwarded email'), +}); + +const CreateDraftSchema = z.object({ + to: z.array(z.string()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject'), + body: z.string().describe('Email body content (supports HTML)'), + cc: z.array(z.string()).optional().describe('Array of CC email addresses'), + isHtml: z.boolean().optional().default(true).describe('Whether the body content is HTML'), +}); + +const MoveEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to move'), + destinationFolder: z + .string() + .describe( + 'Destination folder ID or well-known name (inbox, drafts, sentitems, deleteditems, archive, junkemail)' + ), +}); + +const ListFoldersSchema = z.object({ + top: z.number().optional().default(25).describe('Number of folders to return'), +}); + +const ListEventsSchema = z.object({ + startDateTime: z.string().describe('Start date/time in ISO 8601 format'), + endDateTime: z.string().describe('End date/time in ISO 8601 format'), + top: z.number().optional().default(25).describe('Number of events to return'), +}); + +const CreateEventSchema = z.object({ + subject: z.string().describe('Event subject/title'), + start: z.string().describe('Start date/time in ISO 8601 format'), + end: z.string().describe('End date/time in ISO 8601 format'), + timeZone: z.string().optional().default('UTC').describe('Time zone for the event'), + location: z.string().optional().describe('Event location'), + body: z.string().optional().describe('Event description/body (supports HTML)'), + attendees: z.array(z.string()).optional().describe('Array of attendee email addresses'), + isOnlineMeeting: z.boolean().optional().default(false).describe('Create as online meeting'), +}); + +const UpdateEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to update'), + subject: z.string().optional().describe('New event subject/title'), + start: z.string().optional().describe('New start date/time in ISO 8601 format'), + end: z.string().optional().describe('New end date/time in ISO 8601 format'), + timeZone: z.string().optional().describe('Time zone for the event'), + location: z.string().optional().describe('New event location'), + body: z.string().optional().describe('New event description/body'), +}); + +const DeleteEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to delete'), +}); + +const GetEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to get'), +}); + +const RespondToEventSchema = z.object({ + eventId: z.string().describe('The ID of the event to respond to'), + response: z + .enum(['accept', 'tentativelyAccept', 'decline']) + .describe('Response to the event invitation'), + comment: z.string().optional().describe('Optional comment with the response'), +}); + +const ListContactsSchema = z.object({ + top: z.number().optional().default(25).describe('Number of contacts to return'), + search: z.string().optional().describe('Search query to filter contacts'), +}); + +const CreateContactSchema = z.object({ + givenName: z.string().describe('First name'), + surname: z.string().optional().describe('Last name'), + emailAddresses: z + .array(z.object({ address: z.string(), name: z.string().optional() })) + .optional() + .describe('Email addresses'), + businessPhones: z.array(z.string()).optional().describe('Business phone numbers'), + mobilePhone: z.string().optional().describe('Mobile phone number'), + companyName: z.string().optional().describe('Company name'), + jobTitle: z.string().optional().describe('Job title'), +}); + +const DeleteContactSchema = z.object({ + contactId: z.string().describe('The ID of the contact to delete'), +}); + +const DeleteEmailSchema = z.object({ + messageId: z.string().describe('The ID of the email to delete'), +}); + +// ─── Tool Definitions ─── + +export async function getTools() { + return [ + // Mail tools + { + name: 'outlook_search_emails', + description: + 'Search for emails in Outlook using Microsoft Search syntax. Supports queries like "from:user@example.com", "subject:meeting", or free-text search.', + inputSchema: zodToJsonSchema(SearchEmailsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_read_email', + description: 'Read the full content of a specific email by its ID.', + inputSchema: zodToJsonSchema(ReadEmailSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_send_email', + description: 'Send a new email from the connected Outlook account.', + inputSchema: zodToJsonSchema(SendEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_reply_to_email', + description: 'Reply to an existing email. Supports reply and reply-all.', + inputSchema: zodToJsonSchema(ReplyToEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_forward_email', + description: 'Forward an existing email to new recipients.', + inputSchema: zodToJsonSchema(ForwardEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_create_draft', + description: 'Create a draft email in the Drafts folder.', + inputSchema: zodToJsonSchema(CreateDraftSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_move_email', + description: + 'Move an email to a different folder (inbox, drafts, sentitems, deleteditems, archive, junkemail).', + inputSchema: zodToJsonSchema(MoveEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_delete_email', + description: 'Delete an email (moves to deleted items).', + inputSchema: zodToJsonSchema(DeleteEmailSchema), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + { + name: 'outlook_list_folders', + description: 'List all mail folders in the Outlook account.', + inputSchema: zodToJsonSchema(ListFoldersSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + + // Calendar tools + { + name: 'outlook_list_events', + description: + 'List calendar events within a date range. Returns events from the primary calendar.', + inputSchema: zodToJsonSchema(ListEventsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_get_event', + description: 'Get full details of a specific calendar event.', + inputSchema: zodToJsonSchema(GetEventSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_create_event', + description: + 'Create a new calendar event. Supports setting attendees, location, online meetings, and more.', + inputSchema: zodToJsonSchema(CreateEventSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_update_event', + description: 'Update an existing calendar event.', + inputSchema: zodToJsonSchema(UpdateEventSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_delete_event', + description: 'Delete a calendar event.', + inputSchema: zodToJsonSchema(DeleteEventSchema), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + { + name: 'outlook_respond_to_event', + description: 'Accept, tentatively accept, or decline a calendar event invitation.', + inputSchema: zodToJsonSchema(RespondToEventSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + }, + + // Contacts tools + { + name: 'outlook_list_contacts', + description: 'List contacts from the Outlook address book. Supports search filtering.', + inputSchema: zodToJsonSchema(ListContactsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'outlook_create_contact', + description: 'Create a new contact in the Outlook address book.', + inputSchema: zodToJsonSchema(CreateContactSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'outlook_delete_contact', + description: 'Delete a contact from the Outlook address book.', + inputSchema: zodToJsonSchema(DeleteContactSchema), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + + ...generatedTools, + ]; +} + +// ─── Tool Implementations ─── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatResponse(data: any): string { + return JSON.stringify(data, null, 2); +} + +function toRecipients(emails: string[]) { + return emails.map(email => ({ + emailAddress: { address: email }, + })); +} + +export async function callTool( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: Record, + clientId: string, + clientSecret: string, + redirectUri: string, + credentials: Record +) { + await loadCredentials(clientId, clientSecret, redirectUri, credentials); + + try { + switch (name) { + // ─── Mail ─── + + case 'outlook_search_emails': { + const { query, top, folder } = SearchEmailsSchema.parse(args); + const basePath = folder ? `/me/mailFolders/${folder}/messages` : '/me/messages'; + const response = await graphClient.get(basePath, { + params: { + $search: `"${query}"`, + $top: top, + $select: 'id,subject,from,receivedDateTime,bodyPreview,isRead,importance,webLink', + $orderby: 'receivedDateTime desc', + }, + }); + + const emails = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (msg: any) => ({ + id: msg.id, + subject: msg.subject, + from: msg.from?.emailAddress?.address, + fromName: msg.from?.emailAddress?.name, + receivedDateTime: msg.receivedDateTime, + preview: msg.bodyPreview, + isRead: msg.isRead, + importance: msg.importance, + webLink: msg.webLink, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(emails) }], + }; + } + + case 'outlook_read_email': { + const { messageId } = ReadEmailSchema.parse(args); + const response = await graphClient.get(`/me/messages/${messageId}`, { + params: { + $select: + 'id,subject,from,toRecipients,ccRecipients,body,receivedDateTime,hasAttachments,importance,webLink', + }, + }); + + const msg = response.data; + return { + content: [ + { + type: 'text', + text: formatResponse({ + id: msg.id, + subject: msg.subject, + from: msg.from?.emailAddress, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + to: msg.toRecipients?.map((r: any) => r.emailAddress), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cc: msg.ccRecipients?.map((r: any) => r.emailAddress), + body: msg.body?.content, + bodyType: msg.body?.contentType, + receivedDateTime: msg.receivedDateTime, + hasAttachments: msg.hasAttachments, + importance: msg.importance, + webLink: msg.webLink, + }), + }, + ], + }; + } + + case 'outlook_send_email': { + const { to, subject, body, cc, bcc, isHtml } = SendEmailSchema.parse(args); + await graphClient.post('/me/sendMail', { + message: { + subject, + body: { contentType: isHtml ? 'html' : 'text', content: body }, + toRecipients: toRecipients(to), + ccRecipients: cc ? toRecipients(cc) : undefined, + bccRecipients: bcc ? toRecipients(bcc) : undefined, + }, + }); + + return { + content: [{ type: 'text', text: `Email sent successfully to ${to.join(', ')}` }], + }; + } + + case 'outlook_reply_to_email': { + const { messageId, body, replyAll } = ReplyToEmailSchema.parse(args); + const endpoint = replyAll + ? `/me/messages/${messageId}/replyAll` + : `/me/messages/${messageId}/reply`; + + await graphClient.post(endpoint, { comment: body }); + + return { + content: [ + { type: 'text', text: `Reply sent successfully${replyAll ? ' (reply all)' : ''}` }, + ], + }; + } + + case 'outlook_forward_email': { + const { messageId, to, comment } = ForwardEmailSchema.parse(args); + await graphClient.post(`/me/messages/${messageId}/forward`, { + comment: comment || '', + toRecipients: toRecipients(to), + }); + + return { + content: [ + { type: 'text', text: `Email forwarded successfully to ${to.join(', ')}` }, + ], + }; + } + + case 'outlook_create_draft': { + const { to, subject, body, cc, isHtml } = CreateDraftSchema.parse(args); + const response = await graphClient.post('/me/messages', { + subject, + body: { contentType: isHtml ? 'html' : 'text', content: body }, + toRecipients: toRecipients(to), + ccRecipients: cc ? toRecipients(cc) : undefined, + }); + + return { + content: [ + { type: 'text', text: `Draft created successfully. ID: ${response.data.id}` }, + ], + }; + } + + case 'outlook_move_email': { + const { messageId, destinationFolder } = MoveEmailSchema.parse(args); + const response = await graphClient.post(`/me/messages/${messageId}/move`, { + destinationId: destinationFolder, + }); + + return { + content: [ + { type: 'text', text: `Email moved successfully. New ID: ${response.data.id}` }, + ], + }; + } + + case 'outlook_delete_email': { + const { messageId } = DeleteEmailSchema.parse(args); + await graphClient.delete(`/me/messages/${messageId}`); + + return { + content: [{ type: 'text', text: 'Email deleted successfully' }], + }; + } + + case 'outlook_list_folders': { + const { top } = ListFoldersSchema.parse(args); + const response = await graphClient.get('/me/mailFolders', { + params: { + $top: top, + $select: 'id,displayName,totalItemCount,unreadItemCount', + }, + }); + + const folders = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (f: any) => ({ + id: f.id, + name: f.displayName, + totalItems: f.totalItemCount, + unreadItems: f.unreadItemCount, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(folders) }], + }; + } + + // ─── Calendar ─── + + case 'outlook_list_events': { + const { startDateTime, endDateTime, top } = ListEventsSchema.parse(args); + const response = await graphClient.get('/me/calendarView', { + params: { + startDateTime, + endDateTime, + $top: top, + $select: + 'id,subject,start,end,location,organizer,attendees,isAllDay,webLink,bodyPreview', + $orderby: 'start/dateTime asc', + }, + }); + + const events = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => ({ + id: e.id, + subject: e.subject, + start: e.start, + end: e.end, + location: e.location?.displayName, + organizer: e.organizer?.emailAddress, + attendeeCount: e.attendees?.length || 0, + isAllDay: e.isAllDay, + preview: e.bodyPreview, + webLink: e.webLink, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(events) }], + }; + } + + case 'outlook_get_event': { + const { eventId } = GetEventSchema.parse(args); + const response = await graphClient.get(`/me/events/${eventId}`, { + params: { + $select: + 'id,subject,start,end,location,organizer,attendees,body,isAllDay,webLink,recurrence,onlineMeeting', + }, + }); + + const e = response.data; + return { + content: [ + { + type: 'text', + text: formatResponse({ + id: e.id, + subject: e.subject, + start: e.start, + end: e.end, + location: e.location?.displayName, + organizer: e.organizer?.emailAddress, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attendees: e.attendees?.map((a: any) => ({ + email: a.emailAddress, + status: a.status?.response, + })), + body: e.body?.content, + isAllDay: e.isAllDay, + recurrence: e.recurrence, + onlineMeetingUrl: e.onlineMeeting?.joinUrl, + webLink: e.webLink, + }), + }, + ], + }; + } + + case 'outlook_create_event': { + const { subject, start, end, timeZone, location, body, attendees, isOnlineMeeting } = + CreateEventSchema.parse(args); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventPayload: any = { + subject, + start: { dateTime: start, timeZone }, + end: { dateTime: end, timeZone }, + isOnlineMeeting, + }; + + if (location) eventPayload.location = { displayName: location }; + if (body) eventPayload.body = { contentType: 'html', content: body }; + if (attendees) { + eventPayload.attendees = attendees.map((email: string) => ({ + emailAddress: { address: email }, + type: 'required', + })); + } + + const response = await graphClient.post('/me/events', eventPayload); + + return { + content: [ + { + type: 'text', + text: `Event created: "${subject}" (ID: ${response.data.id})${response.data.webLink ? `\nLink: ${response.data.webLink}` : ''}`, + }, + ], + }; + } + + case 'outlook_update_event': { + const { eventId, subject, start, end, timeZone, location, body } = + UpdateEventSchema.parse(args); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updatePayload: any = {}; + if (subject) updatePayload.subject = subject; + if (start) updatePayload.start = { dateTime: start, timeZone: timeZone || 'UTC' }; + if (end) updatePayload.end = { dateTime: end, timeZone: timeZone || 'UTC' }; + if (location) updatePayload.location = { displayName: location }; + if (body) updatePayload.body = { contentType: 'html', content: body }; + + await graphClient.patch(`/me/events/${eventId}`, updatePayload); + + return { + content: [{ type: 'text', text: `Event ${eventId} updated successfully` }], + }; + } + + case 'outlook_delete_event': { + const { eventId } = DeleteEventSchema.parse(args); + await graphClient.delete(`/me/events/${eventId}`); + + return { + content: [{ type: 'text', text: `Event ${eventId} deleted successfully` }], + }; + } + + case 'outlook_respond_to_event': { + const { eventId, response, comment } = RespondToEventSchema.parse(args); + await graphClient.post(`/me/events/${eventId}/${response}`, { + comment: comment || '', + sendResponse: true, + }); + + return { + content: [ + { type: 'text', text: `Event ${eventId}: responded with "${response}"` }, + ], + }; + } + + // ─── Contacts ─── + + case 'outlook_list_contacts': { + const { top, search } = ListContactsSchema.parse(args); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params: any = { + $top: top, + $select: + 'id,displayName,givenName,surname,emailAddresses,businessPhones,mobilePhone,companyName,jobTitle', + }; + if (search) params.$search = `"${search}"`; + + const response = await graphClient.get('/me/contacts', { params }); + + const contacts = (response.data.value || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => ({ + id: c.id, + displayName: c.displayName, + givenName: c.givenName, + surname: c.surname, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + emails: c.emailAddresses?.map((e: any) => e.address), + businessPhones: c.businessPhones, + mobilePhone: c.mobilePhone, + company: c.companyName, + jobTitle: c.jobTitle, + }) + ); + + return { + content: [{ type: 'text', text: formatResponse(contacts) }], + }; + } + + case 'outlook_create_contact': { + const validated = CreateContactSchema.parse(args); + const response = await graphClient.post('/me/contacts', validated); + + return { + content: [ + { + type: 'text', + text: `Contact created: ${response.data.displayName} (ID: ${response.data.id})`, + }, + ], + }; + } + + case 'outlook_delete_contact': { + const { contactId } = DeleteContactSchema.parse(args); + await graphClient.delete(`/me/contacts/${contactId}`); + + return { + content: [{ type: 'text', text: `Contact ${contactId} deleted successfully` }], + }; + } + + default: { + return await handleGeneratedTool(name, args, graphClient); + } + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = error as any; + const message = err.response?.data?.error?.message || err.message || 'Unknown error'; + return { + content: [{ type: 'text', text: `Error: ${message}` }], + }; + } +} diff --git a/integrations/outlook/src/schedule.ts b/integrations/outlook/src/schedule.ts new file mode 100644 index 00000000..9856e829 --- /dev/null +++ b/integrations/outlook/src/schedule.ts @@ -0,0 +1,274 @@ +import { getGraphClient, OutlookConfig, formatEmailSender, parseEmailBody } from './utils'; +import TurndownService from 'turndown'; + +interface OutlookSettings { + lastSyncTime?: string; + lastUserEventTime?: string; + emailAddress?: string; +} + +interface ActivityCreateParams { + text: string; + sourceURL: string; +} + +function createActivityMessage(params: ActivityCreateParams) { + return { + type: 'activity', + data: { + text: params.text, + sourceURL: params.sourceURL, + }, + }; +} + +function getDefaultSyncTime(): string { + return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); +} + +const turndownService = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + emDelimiter: '*', +}); + +turndownService.remove(['style', 'script', 'noscript', 'iframe', 'object', 'embed']); + +function cleanEmailContent(body: { contentType: string; content: string }): string { + const content = parseEmailBody(body); + if (!content) return ''; + + if (body.contentType === 'html') { + const markdown = turndownService.turndown(content); + return markdown + .replace(/\n\n+/g, '\n\n') + .replace(/\s+/g, ' ') + .trim(); + } + + return content + .replace(/\r/g, '') + .replace(/\n\n+/g, '\n\n') + .replace(/\s+/g, ' ') + .trim(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function processReceivedEmails(client: any, lastSyncTime: string): Promise { + const activities = []; + + try { + const response = await client.get('/me/mailFolders/inbox/messages', { + params: { + $filter: `receivedDateTime ge ${lastSyncTime}`, + $orderby: 'receivedDateTime desc', + $top: 50, + $select: 'id,subject,from,receivedDateTime,body,webLink,isRead,importance', + }, + }); + + const messages = response.data.value || []; + + for (const message of messages) { + try { + const sender = formatEmailSender(message.from); + const subject = message.subject || '(No subject)'; + const content = cleanEmailContent(message.body); + + if (!content || content.length < 10) continue; + + const sourceURL = + message.webLink || `https://outlook.office365.com/mail/inbox/id/${message.id}`; + + const importanceTag = message.importance === 'high' ? ' [HIGH]' : ''; + + const text = `## Email from ${sender}${importanceTag} + +**Subject:** ${subject} + +${content}`; + + activities.push(createActivityMessage({ text, sourceURL })); + } catch (error) { + console.error('Error processing received email:', error); + } + } + } catch (error) { + console.error('Error fetching received emails:', error); + } + + return activities; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function processSentEmails(client: any, lastSyncTime: string): Promise { + const activities = []; + + try { + const response = await client.get('/me/mailFolders/sentitems/messages', { + params: { + $filter: `sentDateTime ge ${lastSyncTime}`, + $orderby: 'sentDateTime desc', + $top: 50, + $select: 'id,subject,toRecipients,sentDateTime,body,webLink', + }, + }); + + const messages = response.data.value || []; + + for (const message of messages) { + try { + const recipients = (message.toRecipients || []) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((r: any) => r.emailAddress?.address || 'Unknown') + .join(', '); + const subject = message.subject || '(No subject)'; + const content = cleanEmailContent(message.body); + + if (!content || content.length < 10) continue; + + const sourceURL = + message.webLink || `https://outlook.office365.com/mail/sentitems/id/${message.id}`; + + const text = `## Sent to ${recipients} + +**Subject:** ${subject} + +${content}`; + + activities.push(createActivityMessage({ text, sourceURL })); + } catch (error) { + console.error('Error processing sent email:', error); + } + } + } catch (error) { + console.error('Error fetching sent emails:', error); + } + + return activities; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function processCalendarEvents(client: any, lastSyncTime: string): Promise { + const activities = []; + + try { + const now = new Date(); + const endTime = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days ahead + + const response = await client.get('/me/calendarView', { + params: { + startDateTime: lastSyncTime, + endDateTime: endTime.toISOString(), + $top: 50, + $select: + 'id,subject,start,end,location,organizer,attendees,webLink,isAllDay,bodyPreview,lastModifiedDateTime', + $orderby: 'start/dateTime asc', + }, + }); + + const events = response.data.value || []; + + for (const event of events) { + try { + // Only include events created or modified since last sync + if (event.lastModifiedDateTime && event.lastModifiedDateTime < lastSyncTime) continue; + + const subject = event.subject || '(No subject)'; + const startStr = event.start?.dateTime + ? new Date(event.start.dateTime).toLocaleString() + : 'TBD'; + const endStr = event.end?.dateTime + ? new Date(event.end.dateTime).toLocaleString() + : 'TBD'; + const location = event.location?.displayName || ''; + const organizer = event.organizer?.emailAddress?.name || ''; + const attendeeCount = event.attendees?.length || 0; + + const sourceURL = + event.webLink || `https://outlook.office365.com/calendar/item/${event.id}`; + + let text = `## Calendar: ${subject} + +**When:** ${startStr} - ${endStr}`; + if (event.isAllDay) text += ' (All day)'; + if (location) text += `\n**Where:** ${location}`; + if (organizer) text += `\n**Organizer:** ${organizer}`; + if (attendeeCount > 0) text += `\n**Attendees:** ${attendeeCount}`; + if (event.bodyPreview) text += `\n\n${event.bodyPreview}`; + + activities.push(createActivityMessage({ text, sourceURL })); + } catch (error) { + console.error('Error processing calendar event:', error); + } + } + } catch (error) { + console.error('Error fetching calendar events:', error); + } + + return activities; +} + +export const handleSchedule = async ( + config?: Record, + state?: Record +) => { + try { + if (!config?.access_token) { + return []; + } + + let settings = (state || {}) as OutlookSettings; + + const lastSyncTime = settings.lastSyncTime || getDefaultSyncTime(); + + const outlookConfig: OutlookConfig = { + access_token: config.access_token, + refresh_token: config.refresh_token || '', + client_id: config.client_id || '', + client_secret: config.client_secret || '', + }; + + const client = await getGraphClient(outlookConfig); + + // Get user profile if not cached + if (!settings.emailAddress) { + try { + const profile = await client.get('/me'); + settings.emailAddress = profile.data.mail || profile.data.userPrincipalName; + } catch (error) { + console.error('Error fetching user profile:', error); + } + } + + const messages = []; + + // Process received emails + const receivedActivities = await processReceivedEmails(client, lastSyncTime); + messages.push(...receivedActivities); + + // Process sent emails + const sentActivities = await processSentEmails(client, lastSyncTime); + messages.push(...sentActivities); + + // Process calendar events + const calendarActivities = await processCalendarEvents(client, lastSyncTime); + messages.push(...calendarActivities); + + // Update state + const newSyncTime = new Date().toISOString(); + messages.push({ + type: 'state', + data: { + ...settings, + lastSyncTime: newSyncTime, + lastUserEventTime: newSyncTime, + }, + }); + + return messages; + } catch (error) { + console.error('Error in handleSchedule:', error); + return []; + } +}; diff --git a/integrations/outlook/src/utils.ts b/integrations/outlook/src/utils.ts new file mode 100644 index 00000000..1601deed --- /dev/null +++ b/integrations/outlook/src/utils.ts @@ -0,0 +1,88 @@ +import axios, { AxiosInstance } from 'axios'; + +const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; + +export interface OutlookConfig { + access_token: string; + refresh_token: string; + client_id: string; + client_secret: string; + redirect_uri?: string; +} + +/** + * Refresh the access token using the refresh token + */ +export async function refreshAccessToken(config: OutlookConfig): Promise { + try { + const response = await axios.post( + 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + new URLSearchParams({ + client_id: config.client_id, + client_secret: config.client_secret, + refresh_token: config.refresh_token, + grant_type: 'refresh_token', + scope: 'https://graph.microsoft.com/.default offline_access', + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + + return response.data.access_token; + } catch (error) { + console.error('Error refreshing access token:', error); + throw error; + } +} + +/** + * Create an authenticated Microsoft Graph API client + */ +export function createGraphClient(accessToken: string): AxiosInstance { + return axios.create({ + baseURL: GRAPH_BASE_URL, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); +} + +/** + * Get an authenticated Graph client, refreshing the token if needed + */ +export async function getGraphClient(config: OutlookConfig): Promise { + let accessToken = config.access_token; + + try { + // Test current token + await axios.get(`${GRAPH_BASE_URL}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } catch { + // Token expired, refresh it + accessToken = await refreshAccessToken(config); + } + + return createGraphClient(accessToken); +} + +/** + * Parse email body content + */ +export function parseEmailBody(body: { contentType: string; content: string }): string { + if (!body || !body.content) return ''; + return body.content; +} + +/** + * Format email sender for display + */ +export function formatEmailSender(from: { + emailAddress: { name: string; address: string }; +}): string { + if (!from || !from.emailAddress) return 'Unknown'; + const { name, address } = from.emailAddress; + return name ? `${name} <${address}>` : address; +} diff --git a/integrations/outlook/tsconfig.json b/integrations/outlook/tsconfig.json new file mode 100644 index 00000000..db52557c --- /dev/null +++ b/integrations/outlook/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "jsx": "react-jsx" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "build", + "bin" + ], + "ts-node": { + "esm": true + } +}