feat(outlook): add outlook webhook provider (#874)

* feat(outlook): add outlook webhook provider

* remove useless files

* cron to one minute

* Update apps/sim/lib/webhooks/utils.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/sim/lib/webhooks/outlook-polling-service.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/sim/lib/webhooks/outlook-polling-service.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix lint

* fix type error:

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Vikhyath Mondreti
2025-08-04 21:32:00 -07:00
committed by GitHub
parent 696ef12c80
commit 64cd60d63a
14 changed files with 1379 additions and 14 deletions

View File

@@ -0,0 +1,66 @@
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { Logger } from '@/lib/logs/console/logger'
import { acquireLock, releaseLock } from '@/lib/redis'
import { pollOutlookWebhooks } from '@/lib/webhooks/outlook-polling-service'
const logger = new Logger('OutlookPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'outlook-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = nanoid()
logger.info(`Outlook webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'Outlook webhook polling')
if (authError) {
return authError
}
lockValue = requestId // unique value to identify the holder
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollOutlookWebhooks()
return NextResponse.json({
success: true,
message: 'Outlook polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during Outlook polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'Outlook polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
await releaseLock(LOCK_KEY).catch(() => {})
}
}

View File

@@ -266,6 +266,40 @@ export async function POST(request: NextRequest) {
}
// --- End Gmail specific logic ---
// --- Outlook webhook setup ---
if (savedWebhook && provider === 'outlook') {
logger.info(
`[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.`
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
const success = await configureOutlookPolling(userId, savedWebhook, requestId)
if (!success) {
logger.error(`[${requestId}] Failed to configure Outlook polling`)
return NextResponse.json(
{
error: 'Failed to configure Outlook polling',
details: 'Please check your Outlook account permissions and try again',
},
{ status: 500 }
)
}
logger.info(`[${requestId}] Successfully configured Outlook polling`)
} catch (err) {
logger.error(`[${requestId}] Error setting up Outlook webhook configuration`, err)
return NextResponse.json(
{
error: 'Failed to configure Outlook webhook',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Outlook specific logic ---
const status = existingWebhooks.length > 0 ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {

View File

@@ -5,6 +5,7 @@ export {
GithubConfig,
GmailConfig,
MicrosoftTeamsConfig,
OutlookConfig,
SlackConfig,
StripeConfig,
TelegramConfig,

View File

@@ -4,6 +4,7 @@ export { GenericConfig } from './generic'
export { GithubConfig } from './github'
export { GmailConfig } from './gmail'
export { MicrosoftTeamsConfig } from './microsoftteams'
export { OutlookConfig } from './outlook'
export { SlackConfig } from './slack'
export { StripeConfig } from './stripe'
export { TelegramConfig } from './telegram'

View File

@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react'
import { Info } from 'lucide-react'
import { OutlookIcon } from '@/components/icons'
import {
Badge,
Button,
Checkbox,
Label,
Notice,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui'
import { Logger } from '@/lib/logs/console/logger'
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components'
const logger = new Logger('OutlookConfig')
interface OutlookFolder {
id: string
name: string
type: string
messagesTotal: number
messagesUnread: number
}
const TOOLTIPS = {
folders:
'Select which Outlook folders to monitor for new emails. Common folders include Inbox, Sent Items, Drafts, etc.',
folderFilterBehavior:
'Choose whether to include emails from the selected folders or exclude them from monitoring.',
markAsRead: 'Automatically mark emails as read after they are processed by the workflow.',
includeRawEmail:
'Include the complete, unprocessed email data from Outlook in the webhook payload. This provides access to all email metadata and headers.',
}
// Generate example payload for Outlook
const generateOutlookExamplePayload = (includeRawEmail: boolean) => {
const baseExample: any = {
email: {
id: 'AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T4KzowKTAAAAAAEMAAAiIsqMbYjsT5e-T4KzowKTAAAYbvZDAAA=',
conversationId:
'AAQkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OAAQAOH_y8jLzUGIn-HVkHUBrEE=',
subject: 'Monthly Report - January 2024',
from: 'sender@company.com',
to: 'recipient@company.com',
cc: '',
date: '2024-01-15T10:30:00Z',
bodyText: 'Hello, Please find attached the monthly report for January 2024.',
bodyHtml: '<p>Hello,</p><p>Please find attached the monthly report for January 2024.</p>',
hasAttachments: true,
isRead: false,
folderId: 'inbox',
},
timestamp: '2024-01-15T10:30:15.123Z',
}
if (includeRawEmail) {
baseExample.rawEmail = {
id: 'AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T4KzowKTAAAAAAEMAAAiIsqMbYjsT5e-T4KzowKTAAAYbvZDAAA=',
conversationId:
'AAQkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OAAQAOH_y8jLzUGIn-HVkHUBrEE=',
subject: 'Monthly Report - January 2024',
bodyPreview: 'Hello, Please find attached the monthly report for January 2024.',
body: {
contentType: 'html',
content: '<p>Hello,</p><p>Please find attached the monthly report for January 2024.</p>',
},
from: {
emailAddress: {
name: 'John Doe',
address: 'sender@company.com',
},
},
toRecipients: [
{
emailAddress: {
name: 'Jane Smith',
address: 'recipient@company.com',
},
},
],
ccRecipients: [],
bccRecipients: [],
receivedDateTime: '2024-01-15T10:30:00Z',
sentDateTime: '2024-01-15T10:29:45Z',
hasAttachments: true,
isRead: false,
isDraft: false,
importance: 'normal',
parentFolderId: 'inbox',
internetMessageId: '<message-id@company.com>',
webLink: 'https://outlook.office365.com/owa/?ItemID=...',
createdDateTime: '2024-01-15T10:30:00Z',
lastModifiedDateTime: '2024-01-15T10:30:15Z',
changeKey: 'CQAAABYAAAAiIsqMbYjsT5e-T4KzowKTAAAYbvZE',
}
}
return baseExample
}
interface OutlookConfigProps {
selectedLabels: string[]
setSelectedLabels: (folders: string[]) => void
labelFilterBehavior: 'INCLUDE' | 'EXCLUDE'
setLabelFilterBehavior: (behavior: 'INCLUDE' | 'EXCLUDE') => void
markAsRead?: boolean
setMarkAsRead?: (markAsRead: boolean) => void
includeRawEmail?: boolean
setIncludeRawEmail?: (includeRawEmail: boolean) => void
}
export function OutlookConfig({
selectedLabels: selectedFolders,
setSelectedLabels: setSelectedFolders,
labelFilterBehavior: folderFilterBehavior,
setLabelFilterBehavior: setFolderFilterBehavior,
markAsRead = false,
setMarkAsRead = () => {},
includeRawEmail = false,
setIncludeRawEmail = () => {},
}: OutlookConfigProps) {
const [folders, setFolders] = useState<OutlookFolder[]>([])
const [isLoadingFolders, setIsLoadingFolders] = useState(false)
const [folderError, setFolderError] = useState<string | null>(null)
// Fetch Outlook folders
useEffect(() => {
let mounted = true
const fetchFolders = async () => {
setIsLoadingFolders(true)
setFolderError(null)
try {
const credentialsResponse = await fetch('/api/auth/oauth/credentials?provider=outlook')
if (!credentialsResponse.ok) {
throw new Error('Failed to get Outlook credentials')
}
const credentialsData = await credentialsResponse.json()
if (!credentialsData.credentials || !credentialsData.credentials.length) {
throw new Error('No Outlook credentials found')
}
const credentialId = credentialsData.credentials[0].id
const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`)
if (!response.ok) {
throw new Error('Failed to fetch Outlook folders')
}
const data = await response.json()
if (data.folders && Array.isArray(data.folders)) {
if (mounted) setFolders(data.folders)
} else {
throw new Error('Invalid folders data format')
}
} catch (error) {
logger.error('Error fetching Outlook folders:', error)
if (mounted) {
setFolderError(error instanceof Error ? error.message : 'Failed to fetch folders')
// Set default folders if API fails
setFolders([
{ id: 'inbox', name: 'Inbox', type: 'folder', messagesTotal: 0, messagesUnread: 0 },
{
id: 'sentitems',
name: 'Sent Items',
type: 'folder',
messagesTotal: 0,
messagesUnread: 0,
},
{ id: 'drafts', name: 'Drafts', type: 'folder', messagesTotal: 0, messagesUnread: 0 },
{
id: 'deleteditems',
name: 'Deleted Items',
type: 'folder',
messagesTotal: 0,
messagesUnread: 0,
},
])
}
} finally {
if (mounted) setIsLoadingFolders(false)
}
}
fetchFolders()
return () => {
mounted = false
}
}, [])
return (
<div className='space-y-6'>
<ConfigSection>
<div className='mb-3 flex items-center gap-2'>
<h3 className='font-medium text-sm'>Email Folders to Monitor</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about email folders'
>
<Info className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>{TOOLTIPS.folders}</p>
</TooltipContent>
</Tooltip>
</div>
{isLoadingFolders ? (
<div className='space-y-2'>
<Skeleton className='h-8 w-full' />
<Skeleton className='h-8 w-full' />
</div>
) : folderError ? (
<Notice variant='warning' className='mb-4'>
<div className='flex items-start gap-2'>
<Info className='mt-0.5 h-4 w-4 flex-shrink-0' />
<div>
<p className='font-medium text-sm'>Unable to load Outlook folders</p>
<p className='text-sm'>{folderError}</p>
<p className='mt-1 text-sm'>
Using default folders. You can still configure the webhook.
</p>
</div>
</div>
</Notice>
) : null}
<div className='space-y-3'>
<div className='flex flex-wrap gap-2'>
{folders.map((folder) => {
const isSelected = selectedFolders.includes(folder.id)
return (
<Badge
key={folder.id}
variant={isSelected ? 'default' : 'secondary'}
className={`cursor-pointer transition-colors ${
isSelected
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'hover:bg-secondary/80'
}`}
onClick={() => {
if (isSelected) {
setSelectedFolders(selectedFolders.filter((id) => id !== folder.id))
} else {
setSelectedFolders([...selectedFolders, folder.id])
}
}}
>
{folder.name}
</Badge>
)
})}
</div>
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2'>
<Label htmlFor='folder-filter-behavior' className='font-normal text-sm'>
Folder behavior:
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about folder filter behavior'
>
<Info className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.folderFilterBehavior}</p>
</TooltipContent>
</Tooltip>
</div>
<Select value={folderFilterBehavior} onValueChange={setFolderFilterBehavior}>
<SelectTrigger className='w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='INCLUDE'>Include</SelectItem>
<SelectItem value='EXCLUDE'>Exclude</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</ConfigSection>
<ConfigSection>
<div className='mb-3'>
<h3 className='font-medium text-sm'>Email Processing Options</h3>
</div>
<div className='space-y-4'>
<div className='flex items-center'>
<div className='flex flex-1 items-center gap-2'>
<Checkbox
id='mark-as-read'
checked={markAsRead}
onCheckedChange={(checked) => setMarkAsRead(checked as boolean)}
/>
<Label htmlFor='mark-as-read' className='cursor-pointer font-normal text-sm'>
Mark emails as read after processing
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about marking emails as read'
>
<Info className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.markAsRead}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className='flex items-center'>
<div className='flex flex-1 items-center gap-2'>
<Checkbox
id='include-raw-email'
checked={includeRawEmail}
onCheckedChange={(checked) => setIncludeRawEmail(checked as boolean)}
/>
<Label htmlFor='include-raw-email' className='cursor-pointer font-normal text-sm'>
Include raw email data
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about raw email data'
>
<Info className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.includeRawEmail}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</ConfigSection>
<ConfigSection>
<div className='mb-3 flex items-center gap-2'>
<OutlookIcon className='h-4 w-4' />
<h3 className='font-medium text-sm'>Outlook Event Payload Example</h3>
</div>
<div className='rounded-md border bg-muted/50 p-3'>
<JSONView data={generateOutlookExamplePayload(includeRawEmail)} />
</div>
</ConfigSection>
</div>
)
}

View File

@@ -17,6 +17,7 @@ import {
GithubConfig,
GmailConfig,
MicrosoftTeamsConfig,
OutlookConfig,
SlackConfig,
StripeConfig,
TelegramConfig,
@@ -125,7 +126,7 @@ export function WebhookModal({
const [includeRawEmail, setIncludeRawEmail] = useState<boolean>(false)
// Get the current provider configuration
const _provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic
// const provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic
// Generate a random verification token if none exists
useEffect(() => {
@@ -262,6 +263,31 @@ export function WebhookModal({
setOriginalValues((prev) => ({ ...prev, markAsRead: config.markAsRead }))
}
if (config.includeRawEmail !== undefined) {
setIncludeRawEmail(config.includeRawEmail)
setOriginalValues((prev) => ({
...prev,
includeRawEmail: config.includeRawEmail,
}))
}
} else if (webhookProvider === 'outlook') {
const folderIds = config.folderIds || []
const folderFilterBehavior = config.folderFilterBehavior || 'INCLUDE'
setSelectedLabels(folderIds) // Reuse selectedLabels for folder IDs
setLabelFilterBehavior(folderFilterBehavior) // Reuse labelFilterBehavior for folders
setOriginalValues((prev) => ({
...prev,
selectedLabels: folderIds,
labelFilterBehavior: folderFilterBehavior,
}))
if (config.markAsRead !== undefined) {
setMarkAsRead(config.markAsRead)
setOriginalValues((prev) => ({ ...prev, markAsRead: config.markAsRead }))
}
if (config.includeRawEmail !== undefined) {
setIncludeRawEmail(config.includeRawEmail)
setOriginalValues((prev) => ({
@@ -431,6 +457,14 @@ export function WebhookModal({
includeRawEmail,
maxEmailsPerPoll: 25,
}
case 'outlook':
return {
folderIds: selectedLabels, // Reuse selectedLabels for folder IDs
folderFilterBehavior: labelFilterBehavior, // Reuse labelFilterBehavior for folders
markAsRead,
includeRawEmail,
maxEmailsPerPoll: 25,
}
case 'generic': {
// Parse the allowed IPs into an array
const parsedIps = allowedIps
@@ -691,6 +725,19 @@ export function WebhookModal({
setIncludeRawEmail={setIncludeRawEmail}
/>
)
case 'outlook':
return (
<OutlookConfig
selectedLabels={selectedLabels}
setSelectedLabels={setSelectedLabels}
labelFilterBehavior={labelFilterBehavior}
setLabelFilterBehavior={setLabelFilterBehavior}
markAsRead={markAsRead}
setMarkAsRead={setMarkAsRead}
includeRawEmail={includeRawEmail}
setIncludeRawEmail={setIncludeRawEmail}
/>
)
case 'discord':
return (
<DiscordConfig

View File

@@ -7,6 +7,7 @@ import {
GithubIcon,
GmailIcon,
MicrosoftTeamsIcon,
OutlookIcon,
SlackIcon,
StripeIcon,
TelegramIcon,
@@ -72,6 +73,15 @@ export interface GmailConfig {
maxEmailsPerPoll?: number
}
export interface OutlookConfig {
credentialId?: string
folderIds?: string[]
folderFilterBehavior?: 'INCLUDE' | 'EXCLUDE'
markAsRead?: boolean
includeRawEmail?: boolean
maxEmailsPerPoll?: number
}
// Define Airtable-specific configuration type
export interface AirtableWebhookConfig {
baseId: string
@@ -100,6 +110,7 @@ export type ProviderConfig =
| AirtableWebhookConfig
| TelegramConfig
| GmailConfig
| OutlookConfig
| MicrosoftTeamsConfig
| Record<string, never>
@@ -170,6 +181,44 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
},
},
},
outlook: {
id: 'outlook',
name: 'Outlook',
icon: (props) => <OutlookIcon {...props} />,
configFields: {
folderFilterBehavior: {
type: 'select',
label: 'Folder Filter Behavior',
options: ['INCLUDE', 'EXCLUDE'],
defaultValue: 'INCLUDE',
description: 'Whether to include or exclude emails from specified folders.',
},
markAsRead: {
type: 'boolean',
label: 'Mark as Read',
defaultValue: false,
description: 'Automatically mark processed emails as read.',
},
includeRawEmail: {
type: 'boolean',
label: 'Include Raw Email Data',
defaultValue: false,
description: 'Include the complete, unprocessed email data from Outlook.',
},
maxEmailsPerPoll: {
type: 'string',
label: 'Max Emails Per Poll',
defaultValue: '10',
description: 'Maximum number of emails to process in each check.',
},
pollingInterval: {
type: 'string',
label: 'Polling Interval (minutes)',
defaultValue: '5',
description: 'How often to check for new emails.',
},
},
},
discord: {
id: 'discord',
name: 'Discord',
@@ -342,6 +391,12 @@ export function WebhookConfig({
// Store Gmail credential from the dedicated subblock
const [storeGmailCredential, setGmailCredential] = useSubBlockValue(blockId, 'gmailCredential')
// Store Outlook credential from the dedicated subblock
const [storeOutlookCredential, setOutlookCredential] = useSubBlockValue(
blockId,
'outlookCredential'
)
// Don't auto-generate webhook paths - only create them when user actually configures a webhook
// This prevents the "Active Webhook" badge from showing on unconfigured blocks
@@ -353,6 +408,7 @@ export function WebhookConfig({
const webhookPath = propValue?.webhookPath ?? storeWebhookPath
const providerConfig = propValue?.providerConfig ?? storeProviderConfig
const gmailCredentialId = storeGmailCredential || ''
const outlookCredentialId = storeOutlookCredential || ''
// Store the actual provider from the database
const [actualProvider, setActualProvider] = useState<string | null>(null)
@@ -447,6 +503,7 @@ export function WebhookConfig({
// Clear component state
setError(null)
setGmailCredential('')
setOutlookCredential('')
// Note: Store will be cleared AFTER successful database deletion
// This ensures store and database stay perfectly in sync
@@ -568,6 +625,11 @@ export function WebhookConfig({
...config,
credentialId: gmailCredentialId,
}
} else if (webhookProvider === 'outlook' && outlookCredentialId) {
finalConfig = {
...config,
credentialId: outlookCredentialId,
}
}
// Set the provider config in the block state
@@ -737,6 +799,51 @@ export function WebhookConfig({
)
}
// For Outlook, show configure button when credential is available and webhook not connected
if (webhookProvider === 'outlook' && !isWebhookConnected) {
return (
<div className='w-full'>
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}
{outlookCredentialId && (
<Button
variant='outline'
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={
isConnecting ||
isSaving ||
isDeleting ||
!outlookCredentialId ||
isPreview ||
disabled
}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
Configure Webhook
</Button>
)}
{isModalOpen && (
<WebhookModal
isOpen={isModalOpen}
onClose={handleCloseModal}
webhookPath={webhookPath || ''}
webhookProvider={webhookProvider || 'generic'}
onSave={handleSaveWebhook}
onDelete={handleDeleteWebhook}
webhookId={webhookId || undefined}
/>
)}
</div>
)
}
return (
<div className='w-full'>
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}

View File

@@ -72,6 +72,48 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] },
required: true,
},
// Advanced Settings - Threading
{
id: 'replyToMessageId',
title: 'Reply to Message ID',
type: 'short-input',
layout: 'full',
placeholder: 'Message ID to reply to (for threading)',
condition: { field: 'operation', value: ['send_outlook'] },
mode: 'advanced',
required: false,
},
{
id: 'conversationId',
title: 'Conversation ID',
type: 'short-input',
layout: 'full',
placeholder: 'Conversation ID for threading',
condition: { field: 'operation', value: ['send_outlook'] },
mode: 'advanced',
required: false,
},
// Advanced Settings - Additional Recipients
{
id: 'cc',
title: 'CC',
type: 'short-input',
layout: 'full',
placeholder: 'CC recipients (comma-separated)',
condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] },
mode: 'advanced',
required: false,
},
{
id: 'bcc',
title: 'BCC',
type: 'short-input',
layout: 'full',
placeholder: 'BCC recipients (comma-separated)',
condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] },
mode: 'advanced',
required: false,
},
// Read Email Fields - Add folder selector (basic mode)
{
id: 'folder',

View File

@@ -4,6 +4,7 @@ import {
GithubIcon,
GmailIcon,
MicrosoftTeamsIcon,
OutlookIcon,
SignalIcon,
SlackIcon,
StripeIcon,
@@ -17,6 +18,7 @@ const getWebhookProviderIcon = (provider: string) => {
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
slack: SlackIcon,
gmail: GmailIcon,
outlook: OutlookIcon,
airtable: AirtableIcon,
telegram: TelegramIcon,
generic: SignalIcon,
@@ -47,6 +49,7 @@ export const WebhookBlock: BlockConfig = {
options: [
'slack',
'gmail',
'outlook',
'airtable',
'telegram',
'generic',
@@ -59,6 +62,7 @@ export const WebhookBlock: BlockConfig = {
const providerLabels = {
slack: 'Slack',
gmail: 'Gmail',
outlook: 'Outlook',
airtable: 'Airtable',
telegram: 'Telegram',
generic: 'Generic',
@@ -93,6 +97,24 @@ export const WebhookBlock: BlockConfig = {
condition: { field: 'webhookProvider', value: 'gmail' },
required: true,
},
{
id: 'outlookCredential',
title: 'Microsoft Account',
type: 'oauth-input',
layout: 'full',
provider: 'outlook',
serviceId: 'outlook',
requiredScopes: [
'Mail.ReadWrite',
'Mail.ReadBasic',
'Mail.Read',
'Mail.Send',
'offline_access',
],
placeholder: 'Select Microsoft account',
condition: { field: 'webhookProvider', value: 'outlook' },
required: true,
},
{
id: 'webhookConfig',
title: 'Webhook Configuration',

View File

@@ -0,0 +1,507 @@
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { Logger } from '@/lib/logs/console/logger'
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
import { getBaseUrl } from '@/lib/urls/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook } from '@/db/schema'
const logger = new Logger('OutlookPollingService')
interface OutlookWebhookConfig {
credentialId: string
folderIds?: string[] // e.g., ['inbox', 'sent']
folderFilterBehavior?: 'INCLUDE' | 'EXCLUDE'
markAsRead?: boolean
maxEmailsPerPoll?: number
lastCheckedTimestamp?: string
processedEmailIds?: string[]
pollingInterval?: number
includeRawEmail?: boolean
}
interface OutlookEmail {
id: string
conversationId: string
subject: string
bodyPreview: string
body: {
contentType: string
content: string
}
from: {
emailAddress: {
name: string
address: string
}
}
toRecipients: Array<{
emailAddress: {
name: string
address: string
}
}>
ccRecipients?: Array<{
emailAddress: {
name: string
address: string
}
}>
receivedDateTime: string
sentDateTime: string
hasAttachments: boolean
isRead: boolean
parentFolderId: string
}
export interface SimplifiedOutlookEmail {
id: string
conversationId: string
subject: string
from: string
to: string
cc: string
date: string
bodyText: string
bodyHtml: string
hasAttachments: boolean
isRead: boolean
folderId: string
// Thread support fields
messageId: string // Same as id, but explicit for threading
threadId: string // Same as conversationId, but explicit for threading
}
export interface OutlookWebhookPayload {
email: SimplifiedOutlookEmail
timestamp: string
rawEmail?: OutlookEmail // Only included when includeRawEmail is true
}
export async function pollOutlookWebhooks() {
logger.info('Starting Outlook webhook polling')
try {
// Get all active Outlook webhooks
const activeWebhooks = await db
.select()
.from(webhook)
.where(and(eq(webhook.provider, 'outlook'), eq(webhook.isActive, true)))
if (!activeWebhooks.length) {
logger.info('No active Outlook webhooks found')
return { total: 0, successful: 0, failed: 0, details: [] }
}
logger.info(`Found ${activeWebhooks.length} active Outlook webhooks`)
// Limit concurrency to avoid exhausting connections
const CONCURRENCY = 10
const running: Promise<any>[] = []
const results: any[] = []
const enqueue = async (webhookData: (typeof activeWebhooks)[number]) => {
const webhookId = webhookData.id
const requestId = nanoid()
try {
logger.info(`[${requestId}] Processing Outlook webhook: ${webhookId}`)
// Extract user ID from webhook metadata if available
const metadata = webhookData.providerConfig as any
const userId = metadata?.userId
// Debug: Webhook metadata extraction
logger.debug(
`[${requestId}] Webhook ${webhookId} providerConfig:`,
JSON.stringify(metadata, null, 2)
)
logger.debug(`[${requestId}] Extracted userId:`, userId)
if (!userId) {
logger.error(`[${requestId}] No user ID found for webhook ${webhookId}`)
logger.debug(`[${requestId}] No userId found in providerConfig for webhook ${webhookId}`)
return { success: false, webhookId, error: 'No user ID' }
}
// Get OAuth token for Outlook API
const accessToken = await getOAuthToken(userId, 'outlook')
if (!accessToken) {
logger.error(`[${requestId}] Failed to get Outlook access token for webhook ${webhookId}`)
return { success: false, webhookId, error: 'No access token' }
}
// Get webhook configuration
const config = webhookData.providerConfig as unknown as OutlookWebhookConfig
const now = new Date()
// Fetch new emails
const fetchResult = await fetchNewOutlookEmails(accessToken, config, requestId)
const { emails } = fetchResult
if (!emails || !emails.length) {
// Update last checked timestamp
await updateWebhookLastChecked(webhookId, now.toISOString())
logger.info(`[${requestId}] No new emails found for webhook ${webhookId}`)
return { success: true, webhookId, status: 'no_emails' }
}
logger.info(`[${requestId}] Found ${emails.length} emails for webhook ${webhookId}`)
// Filter out already processed emails
const processedEmailIds = config.processedEmailIds || []
const newEmails = emails.filter((email) => !processedEmailIds.includes(email.id))
if (!newEmails.length) {
logger.info(`[${requestId}] All emails already processed for webhook ${webhookId}`)
await updateWebhookLastChecked(webhookId, now.toISOString())
return { success: true, webhookId, status: 'all_processed' }
}
logger.info(
`[${requestId}] Processing ${newEmails.length} new emails for webhook ${webhookId}`
)
// Process emails
const processed = await processOutlookEmails(
newEmails,
webhookData,
config,
accessToken,
requestId
)
// Record which email IDs have been processed
const newProcessedIds = [...processedEmailIds, ...newEmails.map((email) => email.id)]
// Keep only the most recent 100 IDs to prevent the list from growing too large
const trimmedProcessedIds = newProcessedIds.slice(-100)
// Update webhook with latest timestamp and processed email IDs
await updateWebhookData(webhookId, now.toISOString(), trimmedProcessedIds)
return {
success: true,
webhookId,
emailsFound: emails.length,
newEmails: newEmails.length,
emailsProcessed: processed,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Error processing Outlook webhook ${webhookId}:`, error)
return { success: false, webhookId, error: errorMessage }
}
}
for (const webhookData of activeWebhooks) {
running.push(enqueue(webhookData))
if (running.length >= CONCURRENCY) {
const result = await Promise.race(running)
running.splice(running.indexOf(result), 1)
results.push(result)
}
}
while (running.length) {
const result = await Promise.race(running)
running.splice(running.indexOf(result), 1)
results.push(result)
}
// Calculate summary
const successful = results.filter((r) => r.success).length
const failed = results.filter((r) => !r.success).length
logger.info(`Outlook polling completed: ${successful} successful, ${failed} failed`)
return {
total: activeWebhooks.length,
successful,
failed,
details: results,
}
} catch (error) {
logger.error('Error during Outlook webhook polling:', error)
throw error
}
}
async function fetchNewOutlookEmails(
accessToken: string,
config: OutlookWebhookConfig,
requestId: string
) {
try {
// Build the Microsoft Graph API URL
const apiUrl = 'https://graph.microsoft.com/v1.0/me/messages'
const params = new URLSearchParams()
// Add select parameters to get the fields we need
params.append(
'$select',
'id,conversationId,subject,bodyPreview,body,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,hasAttachments,isRead,parentFolderId'
)
// Add ordering (newest first)
params.append('$orderby', 'receivedDateTime desc')
// Limit results
params.append('$top', (config.maxEmailsPerPoll || 25).toString())
// Add time filter if we have a last checked timestamp
if (config.lastCheckedTimestamp) {
const lastChecked = new Date(config.lastCheckedTimestamp)
// Add a small buffer to avoid missing emails due to clock differences
const bufferTime = new Date(lastChecked.getTime() - 60000) // 1 minute buffer
params.append('$filter', `receivedDateTime gt ${bufferTime.toISOString()}`)
}
const fullUrl = `${apiUrl}?${params.toString()}`
logger.info(`[${requestId}] Fetching emails from: ${fullUrl}`)
const response = await fetch(fullUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error:`, {
status: response.status,
statusText: response.statusText,
error: errorData,
})
return { emails: [] }
}
const data = await response.json()
const emails = data.value || []
// Filter by folder if configured
const filteredEmails = filterEmailsByFolder(emails, config)
logger.info(
`[${requestId}] Fetched ${emails.length} emails, ${filteredEmails.length} after filtering`
)
return { emails: filteredEmails }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Error fetching new Outlook emails:`, errorMessage)
return { emails: [] }
}
}
function filterEmailsByFolder(
emails: OutlookEmail[],
config: OutlookWebhookConfig
): OutlookEmail[] {
if (!config.folderIds || !config.folderIds.length) {
return emails
}
return emails.filter((email) => {
const emailFolderId = email.parentFolderId
const hasMatchingFolder = config.folderIds!.some((configFolder) =>
emailFolderId.toLowerCase().includes(configFolder.toLowerCase())
)
return config.folderFilterBehavior === 'INCLUDE'
? hasMatchingFolder // Include emails from matching folders
: !hasMatchingFolder // Exclude emails from matching folders
})
}
async function processOutlookEmails(
emails: OutlookEmail[],
webhookData: any,
config: OutlookWebhookConfig,
accessToken: string,
requestId: string
) {
let processedCount = 0
for (const email of emails) {
try {
// Check if we've already processed this email (Redis-based deduplication)
const redisKey = `outlook-email-${email.id}`
const alreadyProcessed = await hasProcessedMessage(redisKey)
if (alreadyProcessed) {
logger.debug(`[${requestId}] Email ${email.id} already processed, skipping`)
continue
}
// Convert to simplified format
const simplifiedEmail: SimplifiedOutlookEmail = {
id: email.id,
conversationId: email.conversationId,
subject: email.subject || '(No Subject)',
from: email.from?.emailAddress?.address || '',
to: email.toRecipients?.map((r) => r.emailAddress.address).join(', ') || '',
cc: email.ccRecipients?.map((r) => r.emailAddress.address).join(', ') || '',
date: email.receivedDateTime,
bodyText: email.bodyPreview || '',
bodyHtml: email.body?.content || '',
hasAttachments: email.hasAttachments,
isRead: email.isRead,
folderId: email.parentFolderId,
// Thread support fields
messageId: email.id,
threadId: email.conversationId,
}
// Create webhook payload
const payload: OutlookWebhookPayload = {
email: simplifiedEmail,
timestamp: new Date().toISOString(),
}
// Include raw email if configured
if (config.includeRawEmail) {
payload.rawEmail = email
}
logger.info(
`[${requestId}] Processing email: ${email.subject} from ${email.from?.emailAddress?.address}`
)
// Trigger the webhook
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': webhookData.secret || '',
'User-Agent': 'SimStudio/1.0',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
logger.error(
`[${requestId}] Failed to trigger webhook for email ${email.id}:`,
response.status,
await response.text()
)
continue
}
// Mark email as read if configured
if (config.markAsRead) {
await markOutlookEmailAsRead(accessToken, email.id)
}
// Mark as processed in Redis (expires after 7 days)
await markMessageAsProcessed(redisKey, 7 * 24 * 60 * 60)
processedCount++
logger.info(`[${requestId}] Successfully processed email ${email.id}`)
} catch (error) {
logger.error(`[${requestId}] Error processing email ${email.id}:`, error)
}
}
return processedCount
}
async function markOutlookEmailAsRead(accessToken: string, messageId: string) {
try {
const response = await fetch(`https://graph.microsoft.com/v1.0/me/messages/${messageId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
isRead: true,
}),
})
if (!response.ok) {
logger.error(
`Failed to mark email ${messageId} as read:`,
response.status,
await response.text()
)
}
} catch (error) {
logger.error(`Error marking email ${messageId} as read:`, error)
}
}
async function updateWebhookLastChecked(webhookId: string, timestamp: string) {
try {
// Get current config first
const currentWebhook = await db
.select({ providerConfig: webhook.providerConfig })
.from(webhook)
.where(eq(webhook.id, webhookId))
.limit(1)
if (!currentWebhook.length) {
logger.error(`Webhook ${webhookId} not found`)
return
}
const currentConfig = (currentWebhook[0].providerConfig as any) || {}
const updatedConfig = {
...currentConfig, // Preserve ALL existing config including userId
lastCheckedTimestamp: timestamp,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
} catch (error) {
logger.error(`Error updating webhook ${webhookId} last checked timestamp:`, error)
}
}
async function updateWebhookData(
webhookId: string,
timestamp: string,
processedEmailIds: string[]
) {
try {
const currentWebhook = await db
.select({ providerConfig: webhook.providerConfig })
.from(webhook)
.where(eq(webhook.id, webhookId))
.limit(1)
if (!currentWebhook.length) {
logger.error(`Webhook ${webhookId} not found`)
return
}
const currentConfig = (currentWebhook[0].providerConfig as any) || {}
const updatedConfig = {
...currentConfig, // Preserve ALL existing config including userId
lastCheckedTimestamp: timestamp,
processedEmailIds,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
} catch (error) {
logger.error(`Error updating webhook ${webhookId} data:`, error)
}
}

View File

@@ -1060,3 +1060,69 @@ export async function configureGmailPolling(
return false
}
}
/**
* Configure Outlook polling for a webhook
*/
export async function configureOutlookPolling(
userId: string,
webhookData: any,
requestId: string
): Promise<boolean> {
const logger = createLogger('OutlookWebhookSetup')
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
try {
const accessToken = await getOAuthToken(userId, 'outlook')
if (!accessToken) {
logger.error(`[${requestId}] Failed to retrieve Outlook access token for user ${userId}`)
return false
}
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const maxEmailsPerPoll =
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
: providerConfig.maxEmailsPerPoll || 25
const pollingInterval =
typeof providerConfig.pollingInterval === 'string'
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
: providerConfig.pollingInterval || 5
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
userId, // Store user ID for OAuth access during polling
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
folderIds: providerConfig.folderIds || ['inbox'],
folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
lastCheckedTimestamp: now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id))
logger.info(
`[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}`
)
return true
} catch (error: any) {
logger.error(`[${requestId}] Failed to configure Outlook polling`, {
webhookId: webhookData.id,
error: error.message,
stack: error.stack,
})
return false
}
}

View File

@@ -37,10 +37,39 @@ export const outlookSendTool: ToolConfig<OutlookSendParams, OutlookSendResponse>
visibility: 'user-or-llm',
description: 'Email body content',
},
replyToMessageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Message ID to reply to (for threading)',
},
conversationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Conversation ID for threading',
},
cc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'CC recipients (comma-separated)',
},
bcc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'BCC recipients (comma-separated)',
},
},
request: {
url: (params) => {
// If replying to a specific message, use the reply endpoint
if (params.replyToMessageId) {
return `https://graph.microsoft.com/v1.0/me/messages/${params.replyToMessageId}/reply`
}
// Otherwise use the regular send mail endpoint
return `https://graph.microsoft.com/v1.0/me/sendMail`
},
method: 'POST',
@@ -56,21 +85,70 @@ export const outlookSendTool: ToolConfig<OutlookSendParams, OutlookSendResponse>
}
},
body: (params: OutlookSendParams): Record<string, any> => {
return {
message: {
subject: params.subject,
body: {
contentType: 'Text',
content: params.body,
},
toRecipients: [
{
emailAddress: {
address: params.to,
},
// Helper function to parse comma-separated emails
const parseEmails = (emailString?: string) => {
if (!emailString) return []
return emailString
.split(',')
.map((email) => email.trim())
.filter((email) => email.length > 0)
.map((email) => ({ emailAddress: { address: email } }))
}
// If replying to a message, use the reply format
if (params.replyToMessageId) {
const replyBody: any = {
message: {
body: {
contentType: 'Text',
content: params.body,
},
],
},
}
// Add CC/BCC if provided
const ccRecipients = parseEmails(params.cc)
const bccRecipients = parseEmails(params.bcc)
if (ccRecipients.length > 0) {
replyBody.message.ccRecipients = ccRecipients
}
if (bccRecipients.length > 0) {
replyBody.message.bccRecipients = bccRecipients
}
return replyBody
}
// Regular send mail format
const toRecipients = parseEmails(params.to)
const ccRecipients = parseEmails(params.cc)
const bccRecipients = parseEmails(params.bcc)
const message: any = {
subject: params.subject,
body: {
contentType: 'Text',
content: params.body,
},
toRecipients,
}
// Add CC/BCC if provided
if (ccRecipients.length > 0) {
message.ccRecipients = ccRecipients
}
if (bccRecipients.length > 0) {
message.bccRecipients = bccRecipients
}
// Add conversation ID for threading if provided
if (params.conversationId) {
message.conversationId = params.conversationId
}
return {
message,
saveToSentItems: true,
}
},

View File

@@ -5,6 +5,11 @@ export interface OutlookSendParams {
to: string
subject: string
body: string
// Thread support parameters
replyToMessageId?: string
conversationId?: string
cc?: string
bcc?: string
}
export interface OutlookSendResponse extends ToolResponse {

View File

@@ -8,6 +8,10 @@
"path": "/api/webhooks/poll/gmail",
"schedule": "*/1 * * * *"
},
{
"path": "/api/webhooks/poll/outlook",
"schedule": "*/1 * * * *"
},
{
"path": "/api/logs/cleanup",
"schedule": "0 0 * * *"