mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
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:
committed by
GitHub
parent
696ef12c80
commit
64cd60d63a
66
apps/sim/app/api/webhooks/poll/outlook/route.ts
Normal file
66
apps/sim/app/api/webhooks/poll/outlook/route.ts
Normal 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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
GithubConfig,
|
||||
GmailConfig,
|
||||
MicrosoftTeamsConfig,
|
||||
OutlookConfig,
|
||||
SlackConfig,
|
||||
StripeConfig,
|
||||
TelegramConfig,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
507
apps/sim/lib/webhooks/outlook-polling-service.ts
Normal file
507
apps/sim/lib/webhooks/outlook-polling-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 * * *"
|
||||
|
||||
Reference in New Issue
Block a user