mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 06:33:52 -05:00
feat(imap): added support for imap trigger (#2663)
* feat(tools): added support for imap trigger * feat(imap): added parity, tested * ack PR comments * final cleanup
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -58,6 +58,7 @@ import {
|
||||
LinkupIcon,
|
||||
MailchimpIcon,
|
||||
MailgunIcon,
|
||||
MailServerIcon,
|
||||
Mem0Icon,
|
||||
MicrosoftExcelIcon,
|
||||
MicrosoftOneDriveIcon,
|
||||
@@ -165,6 +166,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
image_generator: ImageIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
intercom: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
|
||||
40
apps/docs/content/docs/en/tools/imap.mdx
Normal file
40
apps/docs/content/docs/en/tools/imap.mdx
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: IMAP Email
|
||||
description: Trigger workflows when new emails arrive via IMAP (works with any email provider)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="imap"
|
||||
color="#6366F1"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The IMAP Email trigger allows your Sim workflows to start automatically whenever a new email is received in any mailbox that supports the IMAP protocol. This works with Gmail, Outlook, Yahoo, and most other email providers.
|
||||
|
||||
With the IMAP trigger, you can:
|
||||
|
||||
- **Automate email processing**: Start workflows in real time when new messages arrive in your inbox.
|
||||
- **Filter by sender, subject, or folder**: Configure your trigger to react only to emails that match certain conditions.
|
||||
- **Extract and process attachments**: Automatically download and use file attachments in your automated flows.
|
||||
- **Parse and use email content**: Access the subject, sender, recipients, full body, and other metadata in downstream workflow steps.
|
||||
- **Integrate with any email provider**: Works with any service that provides standard IMAP access, without vendor lock-in.
|
||||
- **Trigger on unread, flagged, or custom criteria**: Set up advanced filters for the kinds of emails that start your workflows.
|
||||
|
||||
With Sim, the IMAP integration gives you the power to turn email into an actionable source of automation. Respond to customer inquiries, process notifications, kick off data pipelines, and more—directly from your email inbox, with no manual intervention.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to any email server via IMAP protocol to trigger workflows when new emails are received. Supports Gmail, Outlook, Yahoo, and any other IMAP-compatible email provider.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `triggers`
|
||||
- Type: `imap`
|
||||
@@ -42,6 +42,7 @@
|
||||
"huggingface",
|
||||
"hunter",
|
||||
"image_generator",
|
||||
"imap",
|
||||
"incidentio",
|
||||
"intercom",
|
||||
"jina",
|
||||
|
||||
101
apps/sim/app/api/tools/imap/mailboxes/route.ts
Normal file
101
apps/sim/app/api/tools/imap/mailboxes/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('ImapMailboxesAPI')
|
||||
|
||||
interface ImapMailboxRequest {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
rejectUnauthorized: boolean
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as ImapMailboxRequest
|
||||
const { host, port, secure, rejectUnauthorized, username, password } = body
|
||||
|
||||
if (!host || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Missing required fields: host, username, password' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port: port || 993,
|
||||
secure: secure ?? true,
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: rejectUnauthorized ?? true,
|
||||
},
|
||||
logger: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
|
||||
const listResult = await client.list()
|
||||
const mailboxes = listResult.map((mailbox) => ({
|
||||
path: mailbox.path,
|
||||
name: mailbox.name,
|
||||
delimiter: mailbox.delimiter,
|
||||
}))
|
||||
|
||||
await client.logout()
|
||||
|
||||
mailboxes.sort((a, b) => {
|
||||
if (a.path === 'INBOX') return -1
|
||||
if (b.path === 'INBOX') return 1
|
||||
return a.path.localeCompare(b.path)
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mailboxes,
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.logout()
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Error fetching IMAP mailboxes:', errorMessage)
|
||||
|
||||
let userMessage = 'Failed to connect to IMAP server'
|
||||
if (
|
||||
errorMessage.includes('AUTHENTICATIONFAILED') ||
|
||||
errorMessage.includes('Invalid credentials')
|
||||
) {
|
||||
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
|
||||
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
|
||||
userMessage = 'Could not find IMAP server. Please check the hostname.'
|
||||
} else if (errorMessage.includes('ECONNREFUSED')) {
|
||||
userMessage = 'Connection refused. Please check the port and SSL settings.'
|
||||
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
|
||||
userMessage =
|
||||
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage = 'Connection timed out. Please check your network and server settings.'
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
68
apps/sim/app/api/webhooks/poll/imap/route.ts
Normal file
68
apps/sim/app/api/webhooks/poll/imap/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
|
||||
import { pollImapWebhooks } from '@/lib/webhooks/imap-polling-service'
|
||||
|
||||
const logger = createLogger('ImapPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
const LOCK_KEY = 'imap-polling-lock'
|
||||
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = nanoid()
|
||||
logger.info(`IMAP webhook polling triggered (${requestId})`)
|
||||
|
||||
let lockValue: string | undefined
|
||||
|
||||
try {
|
||||
const authError = verifyCronAuth(request, 'IMAP 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 pollImapWebhooks()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'IMAP polling completed',
|
||||
requestId,
|
||||
status: 'completed',
|
||||
...results,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error during IMAP polling (${requestId}):`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'IMAP polling failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
requestId,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
if (lockValue) {
|
||||
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,13 @@ export function Dropdown({
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
const singleValue = multiSelect ? null : (value as string | null | undefined)
|
||||
const multiValues = multiSelect ? (value as string[] | null | undefined) || [] : null
|
||||
const multiValues = multiSelect
|
||||
? Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value as string]
|
||||
: []
|
||||
: null
|
||||
|
||||
const fetchOptionsIfNeeded = useCallback(async () => {
|
||||
if (!fetchOptions || isPreview || disabled) return
|
||||
|
||||
53
apps/sim/blocks/blocks/imap.ts
Normal file
53
apps/sim/blocks/blocks/imap.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { MailServerIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
export const ImapBlock: BlockConfig = {
|
||||
type: 'imap',
|
||||
name: 'IMAP Email',
|
||||
description: 'Trigger workflows when new emails arrive via IMAP (works with any email provider)',
|
||||
longDescription:
|
||||
'Connect to any email server via IMAP protocol to trigger workflows when new emails are received. Supports Gmail, Outlook, Yahoo, and any other IMAP-compatible email provider.',
|
||||
category: 'triggers',
|
||||
bgColor: '#6366F1',
|
||||
icon: MailServerIcon,
|
||||
triggerAllowed: true,
|
||||
hideFromToolbar: false,
|
||||
subBlocks: [...getTrigger('imap_poller').subBlocks],
|
||||
tools: {
|
||||
access: [],
|
||||
config: {
|
||||
tool: () => '',
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
host: { type: 'string', description: 'IMAP server hostname' },
|
||||
port: { type: 'string', description: 'IMAP server port' },
|
||||
secure: { type: 'boolean', description: 'Use SSL/TLS encryption' },
|
||||
rejectUnauthorized: { type: 'boolean', description: 'Verify TLS certificate' },
|
||||
username: { type: 'string', description: 'Email username' },
|
||||
password: { type: 'string', description: 'Email password' },
|
||||
mailbox: { type: 'string', description: 'Mailbox to monitor' },
|
||||
searchCriteria: { type: 'string', description: 'IMAP search criteria' },
|
||||
markAsRead: { type: 'boolean', description: 'Mark emails as read after processing' },
|
||||
includeAttachments: { type: 'boolean', description: 'Include email attachments' },
|
||||
},
|
||||
outputs: {
|
||||
messageId: { type: 'string', description: 'RFC Message-ID header' },
|
||||
subject: { type: 'string', description: 'Email subject line' },
|
||||
from: { type: 'string', description: 'Sender email address' },
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
cc: { type: 'string', description: 'CC recipients' },
|
||||
date: { type: 'string', description: 'Email date in ISO format' },
|
||||
bodyText: { type: 'string', description: 'Plain text email body' },
|
||||
bodyHtml: { type: 'string', description: 'HTML email body' },
|
||||
mailbox: { type: 'string', description: 'Mailbox/folder where email was received' },
|
||||
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
|
||||
attachments: { type: 'json', description: 'Array of email attachments' },
|
||||
timestamp: { type: 'string', description: 'Event timestamp' },
|
||||
},
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: ['imap_poller'],
|
||||
},
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import { HuggingFaceBlock } from '@/blocks/blocks/huggingface'
|
||||
import { HumanInTheLoopBlock } from '@/blocks/blocks/human_in_the_loop'
|
||||
import { HunterBlock } from '@/blocks/blocks/hunter'
|
||||
import { ImageGeneratorBlock } from '@/blocks/blocks/image_generator'
|
||||
import { ImapBlock } from '@/blocks/blocks/imap'
|
||||
import { IncidentioBlock } from '@/blocks/blocks/incidentio'
|
||||
import { InputTriggerBlock } from '@/blocks/blocks/input_trigger'
|
||||
import { IntercomBlock } from '@/blocks/blocks/intercom'
|
||||
@@ -196,6 +197,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
human_in_the_loop: HumanInTheLoopBlock,
|
||||
hunter: HunterBlock,
|
||||
image_generator: ImageGeneratorBlock,
|
||||
imap: ImapBlock,
|
||||
incidentio: IncidentioBlock,
|
||||
input_trigger: InputTriggerBlock,
|
||||
intercom: IntercomBlock,
|
||||
|
||||
@@ -295,6 +295,56 @@ export function MailIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function MailServerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect
|
||||
x='3'
|
||||
y='4'
|
||||
width='18'
|
||||
height='16'
|
||||
rx='2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M3 8L10.89 13.26C11.2187 13.4793 11.6049 13.5963 12 13.5963C12.3951 13.5963 12.7813 13.4793 13.11 13.26L21 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<line
|
||||
x1='7'
|
||||
y1='16'
|
||||
x2='7'
|
||||
y2='16'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
<line
|
||||
x1='10'
|
||||
y1='16'
|
||||
x2='10'
|
||||
y2='16'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -238,18 +238,20 @@ export async function pollGmailWebhooks() {
|
||||
}
|
||||
|
||||
for (const webhookData of activeWebhooks) {
|
||||
const promise = enqueue(webhookData)
|
||||
.then(() => {})
|
||||
const promise: Promise<void> = enqueue(webhookData)
|
||||
.catch((err) => {
|
||||
logger.error('Unexpected error in webhook processing:', err)
|
||||
failureCount++
|
||||
})
|
||||
.finally(() => {
|
||||
const idx = running.indexOf(promise)
|
||||
if (idx !== -1) running.splice(idx, 1)
|
||||
})
|
||||
|
||||
running.push(promise)
|
||||
|
||||
if (running.length >= CONCURRENCY) {
|
||||
const completedIdx = await Promise.race(running.map((p, i) => p.then(() => i)))
|
||||
running.splice(completedIdx, 1)
|
||||
await Promise.race(running)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
706
apps/sim/lib/webhooks/imap-polling-service.ts
Normal file
706
apps/sim/lib/webhooks/imap-polling-service.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import type { FetchMessageObject, MailboxLockObject } from 'imapflow'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('ImapPollingService')
|
||||
|
||||
type WebhookRecord = InferSelectModel<typeof webhook>
|
||||
|
||||
interface ImapWebhookConfig {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
rejectUnauthorized: boolean
|
||||
username: string
|
||||
password: string
|
||||
mailbox: string | string[] // Can be single mailbox or array of mailboxes
|
||||
searchCriteria: string
|
||||
markAsRead: boolean
|
||||
includeAttachments: boolean
|
||||
lastProcessedUid?: number
|
||||
lastProcessedUidByMailbox?: Record<string, number> // Track UID per mailbox for multi-mailbox
|
||||
lastCheckedTimestamp?: string // ISO timestamp of last successful poll
|
||||
maxEmailsPerPoll?: number
|
||||
}
|
||||
|
||||
interface ImapAttachment {
|
||||
name: string
|
||||
data: Buffer
|
||||
mimeType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface SimplifiedImapEmail {
|
||||
uid: string
|
||||
messageId: string
|
||||
subject: string
|
||||
from: string
|
||||
to: string
|
||||
cc: string
|
||||
date: string | null
|
||||
bodyText: string
|
||||
bodyHtml: string
|
||||
mailbox: string
|
||||
hasAttachments: boolean
|
||||
attachments: ImapAttachment[]
|
||||
}
|
||||
|
||||
export interface ImapWebhookPayload {
|
||||
email: SimplifiedImapEmail
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
async function markWebhookFailed(webhookId: string) {
|
||||
try {
|
||||
const result = await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
failedCount: sql`COALESCE(${webhook.failedCount}, 0) + 1`,
|
||||
lastFailedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
.returning({ failedCount: webhook.failedCount })
|
||||
|
||||
const newFailedCount = result[0]?.failedCount || 0
|
||||
const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES
|
||||
|
||||
if (shouldDisable) {
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
isActive: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
|
||||
logger.warn(
|
||||
`Webhook ${webhookId} auto-disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to mark webhook ${webhookId} as failed:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function markWebhookSuccess(webhookId: string) {
|
||||
try {
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
failedCount: 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
} catch (err) {
|
||||
logger.error(`Failed to mark webhook ${webhookId} as successful:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollImapWebhooks() {
|
||||
logger.info('Starting IMAP webhook polling')
|
||||
|
||||
try {
|
||||
const activeWebhooksResult = await db
|
||||
.select({ webhook })
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(
|
||||
and(eq(webhook.provider, 'imap'), eq(webhook.isActive, true), eq(workflow.isDeployed, true))
|
||||
)
|
||||
|
||||
const activeWebhooks = activeWebhooksResult.map((r) => r.webhook)
|
||||
|
||||
if (!activeWebhooks.length) {
|
||||
logger.info('No active IMAP webhooks found')
|
||||
return { total: 0, successful: 0, failed: 0, details: [] }
|
||||
}
|
||||
|
||||
logger.info(`Found ${activeWebhooks.length} active IMAP webhooks`)
|
||||
|
||||
const CONCURRENCY = 5
|
||||
|
||||
const running: Promise<void>[] = []
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
const enqueue = async (webhookData: (typeof activeWebhooks)[number]) => {
|
||||
const webhookId = webhookData.id
|
||||
const requestId = nanoid()
|
||||
|
||||
try {
|
||||
const config = webhookData.providerConfig as unknown as ImapWebhookConfig
|
||||
|
||||
if (!config.host || !config.username || !config.password) {
|
||||
logger.error(`[${requestId}] Missing IMAP credentials for webhook ${webhookId}`)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
return
|
||||
}
|
||||
|
||||
const fetchResult = await fetchNewEmails(config, requestId)
|
||||
const { emails, latestUidByMailbox } = fetchResult
|
||||
const pollTimestamp = new Date().toISOString()
|
||||
|
||||
if (!emails || !emails.length) {
|
||||
await updateWebhookLastProcessedUids(webhookId, latestUidByMailbox, pollTimestamp)
|
||||
await markWebhookSuccess(webhookId)
|
||||
logger.info(`[${requestId}] No new emails found for webhook ${webhookId}`)
|
||||
successCount++
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Found ${emails.length} new emails for webhook ${webhookId}`)
|
||||
|
||||
const { processedCount, failedCount: emailFailedCount } = await processEmails(
|
||||
emails,
|
||||
webhookData,
|
||||
config,
|
||||
requestId
|
||||
)
|
||||
|
||||
await updateWebhookLastProcessedUids(webhookId, latestUidByMailbox, pollTimestamp)
|
||||
|
||||
if (emailFailedCount > 0 && processedCount === 0) {
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
logger.warn(
|
||||
`[${requestId}] All ${emailFailedCount} emails failed to process for webhook ${webhookId}`
|
||||
)
|
||||
} else {
|
||||
await markWebhookSuccess(webhookId)
|
||||
successCount++
|
||||
logger.info(
|
||||
`[${requestId}] Successfully processed ${processedCount} emails for webhook ${webhookId}${emailFailedCount > 0 ? ` (${emailFailedCount} failed)` : ''}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing IMAP webhook ${webhookId}:`, error)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
}
|
||||
}
|
||||
|
||||
for (const webhookData of activeWebhooks) {
|
||||
const promise: Promise<void> = enqueue(webhookData)
|
||||
.catch((err) => {
|
||||
logger.error('Unexpected error in webhook processing:', err)
|
||||
failureCount++
|
||||
})
|
||||
.finally(() => {
|
||||
// Self-remove from running array when completed
|
||||
const idx = running.indexOf(promise)
|
||||
if (idx !== -1) running.splice(idx, 1)
|
||||
})
|
||||
|
||||
running.push(promise)
|
||||
|
||||
if (running.length >= CONCURRENCY) {
|
||||
await Promise.race(running)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(running)
|
||||
|
||||
const summary = {
|
||||
total: activeWebhooks.length,
|
||||
successful: successCount,
|
||||
failed: failureCount,
|
||||
details: [],
|
||||
}
|
||||
|
||||
logger.info('IMAP polling completed', {
|
||||
total: summary.total,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
})
|
||||
|
||||
return summary
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Error in IMAP polling service:', errorMessage)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewEmails(config: ImapWebhookConfig, requestId: string) {
|
||||
const client = new ImapFlow({
|
||||
host: config.host,
|
||||
port: config.port || 993,
|
||||
secure: config.secure ?? true,
|
||||
auth: {
|
||||
user: config.username,
|
||||
pass: config.password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: config.rejectUnauthorized ?? true,
|
||||
},
|
||||
logger: false,
|
||||
})
|
||||
|
||||
const emails: Array<{
|
||||
uid: number
|
||||
mailboxPath: string // Track which mailbox this email came from
|
||||
envelope: FetchMessageObject['envelope']
|
||||
bodyStructure: FetchMessageObject['bodyStructure']
|
||||
source?: Buffer
|
||||
}> = []
|
||||
|
||||
const mailboxes = getMailboxesToCheck(config)
|
||||
const latestUidByMailbox: Record<string, number> = { ...(config.lastProcessedUidByMailbox || {}) }
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
logger.debug(`[${requestId}] Connected to IMAP server ${config.host}`)
|
||||
|
||||
const maxEmails = config.maxEmailsPerPoll || 25
|
||||
let totalEmailsCollected = 0
|
||||
|
||||
for (const mailboxPath of mailboxes) {
|
||||
if (totalEmailsCollected >= maxEmails) break
|
||||
|
||||
try {
|
||||
const mailbox = await client.mailboxOpen(mailboxPath)
|
||||
logger.debug(`[${requestId}] Opened mailbox: ${mailbox.path}, exists: ${mailbox.exists}`)
|
||||
|
||||
// Parse search criteria - expects JSON object from UI
|
||||
let searchCriteria: any = { unseen: true }
|
||||
if (config.searchCriteria) {
|
||||
if (typeof config.searchCriteria === 'object') {
|
||||
searchCriteria = config.searchCriteria
|
||||
} else if (typeof config.searchCriteria === 'string') {
|
||||
try {
|
||||
searchCriteria = JSON.parse(config.searchCriteria)
|
||||
} catch {
|
||||
logger.warn(`[${requestId}] Invalid search criteria JSON, using default`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastUidForMailbox = latestUidByMailbox[mailboxPath] || config.lastProcessedUid
|
||||
|
||||
if (lastUidForMailbox) {
|
||||
searchCriteria = { ...searchCriteria, uid: `${lastUidForMailbox + 1}:*` }
|
||||
}
|
||||
|
||||
// Add time-based filtering similar to Gmail
|
||||
// If lastCheckedTimestamp exists, use it with 1 minute buffer
|
||||
// If first poll (no timestamp), default to last 24 hours to avoid processing ALL unseen emails
|
||||
if (config.lastCheckedTimestamp) {
|
||||
const lastChecked = new Date(config.lastCheckedTimestamp)
|
||||
const bufferTime = new Date(lastChecked.getTime() - 60000)
|
||||
searchCriteria = { ...searchCriteria, since: bufferTime }
|
||||
} else {
|
||||
// First poll: only get emails from last 24 hours to avoid overwhelming first run
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
searchCriteria = { ...searchCriteria, since: oneDayAgo }
|
||||
}
|
||||
|
||||
let messageUids: number[] = []
|
||||
try {
|
||||
const searchResult = await client.search(searchCriteria, { uid: true })
|
||||
messageUids = searchResult === false ? [] : searchResult
|
||||
} catch (searchError) {
|
||||
logger.debug(
|
||||
`[${requestId}] Search returned no messages for ${mailboxPath}: ${searchError}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (messageUids.length === 0) {
|
||||
logger.debug(`[${requestId}] No messages matching criteria in ${mailboxPath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
messageUids.sort((a, b) => a - b) // Sort ascending to process oldest first
|
||||
const remainingSlots = maxEmails - totalEmailsCollected
|
||||
const uidsToProcess = messageUids.slice(0, remainingSlots)
|
||||
|
||||
if (uidsToProcess.length > 0) {
|
||||
latestUidByMailbox[mailboxPath] = Math.max(
|
||||
...uidsToProcess,
|
||||
latestUidByMailbox[mailboxPath] || 0
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Processing ${uidsToProcess.length} emails from ${mailboxPath}`)
|
||||
|
||||
for await (const msg of client.fetch(
|
||||
uidsToProcess,
|
||||
{
|
||||
uid: true,
|
||||
envelope: true,
|
||||
bodyStructure: true,
|
||||
source: true,
|
||||
},
|
||||
{ uid: true }
|
||||
)) {
|
||||
emails.push({
|
||||
uid: msg.uid,
|
||||
mailboxPath,
|
||||
envelope: msg.envelope,
|
||||
bodyStructure: msg.bodyStructure,
|
||||
source: msg.source,
|
||||
})
|
||||
totalEmailsCollected++
|
||||
}
|
||||
} catch (mailboxError) {
|
||||
logger.warn(`[${requestId}] Error processing mailbox ${mailboxPath}:`, mailboxError)
|
||||
}
|
||||
}
|
||||
|
||||
await client.logout()
|
||||
logger.debug(`[${requestId}] Disconnected from IMAP server`)
|
||||
|
||||
return { emails, latestUidByMailbox }
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.logout()
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of mailboxes to check based on config
|
||||
*/
|
||||
function getMailboxesToCheck(config: ImapWebhookConfig): string[] {
|
||||
if (!config.mailbox || (Array.isArray(config.mailbox) && config.mailbox.length === 0)) {
|
||||
return ['INBOX']
|
||||
}
|
||||
if (Array.isArray(config.mailbox)) {
|
||||
return config.mailbox
|
||||
}
|
||||
return [config.mailbox]
|
||||
}
|
||||
|
||||
function parseEmailAddress(
|
||||
addr: { name?: string; address?: string } | { name?: string; address?: string }[] | undefined
|
||||
): string {
|
||||
if (!addr) return ''
|
||||
if (Array.isArray(addr)) {
|
||||
return addr
|
||||
.map((a) => (a.name ? `${a.name} <${a.address}>` : a.address || ''))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
return addr.name ? `${addr.name} <${addr.address}>` : addr.address || ''
|
||||
}
|
||||
|
||||
function extractTextFromSource(source: Buffer): { text: string; html: string } {
|
||||
const content = source.toString('utf-8')
|
||||
let text = ''
|
||||
let html = ''
|
||||
|
||||
const parts = content.split(/--[^\r\n]+/)
|
||||
|
||||
for (const part of parts) {
|
||||
const lowerPart = part.toLowerCase()
|
||||
|
||||
if (lowerPart.includes('content-type: text/plain')) {
|
||||
const match = part.match(/\r?\n\r?\n([\s\S]*?)(?=\r?\n--|\r?\n\.\r?\n|$)/i)
|
||||
if (match) {
|
||||
text = match[1].trim()
|
||||
if (lowerPart.includes('quoted-printable')) {
|
||||
text = text
|
||||
.replace(/=\r?\n/g, '')
|
||||
.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
}
|
||||
if (lowerPart.includes('base64')) {
|
||||
try {
|
||||
text = Buffer.from(text.replace(/\s/g, ''), 'base64').toString('utf-8')
|
||||
} catch {
|
||||
// Keep as-is if base64 decode fails
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (lowerPart.includes('content-type: text/html')) {
|
||||
const match = part.match(/\r?\n\r?\n([\s\S]*?)(?=\r?\n--|\r?\n\.\r?\n|$)/i)
|
||||
if (match) {
|
||||
html = match[1].trim()
|
||||
if (lowerPart.includes('quoted-printable')) {
|
||||
html = html
|
||||
.replace(/=\r?\n/g, '')
|
||||
.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
}
|
||||
if (lowerPart.includes('base64')) {
|
||||
try {
|
||||
html = Buffer.from(html.replace(/\s/g, ''), 'base64').toString('utf-8')
|
||||
} catch {
|
||||
// Keep as-is if base64 decode fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!text && !html) {
|
||||
const bodyMatch = content.match(/\r?\n\r?\n([\s\S]+)$/)
|
||||
if (bodyMatch) {
|
||||
text = bodyMatch[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
return { text, html }
|
||||
}
|
||||
|
||||
function extractAttachmentsFromSource(
|
||||
source: Buffer,
|
||||
bodyStructure: FetchMessageObject['bodyStructure']
|
||||
): ImapAttachment[] {
|
||||
const attachments: ImapAttachment[] = []
|
||||
|
||||
if (!bodyStructure) return attachments
|
||||
|
||||
const content = source.toString('utf-8')
|
||||
const parts = content.split(/--[^\r\n]+/)
|
||||
|
||||
for (const part of parts) {
|
||||
const lowerPart = part.toLowerCase()
|
||||
|
||||
const dispositionMatch = part.match(
|
||||
/content-disposition:\s*attachment[^;]*;\s*filename="?([^"\r\n]+)"?/i
|
||||
)
|
||||
const filenameMatch = part.match(/name="?([^"\r\n]+)"?/i)
|
||||
const contentTypeMatch = part.match(/content-type:\s*([^;\r\n]+)/i)
|
||||
|
||||
if (
|
||||
dispositionMatch ||
|
||||
(filenameMatch && !lowerPart.includes('text/plain') && !lowerPart.includes('text/html'))
|
||||
) {
|
||||
const filename = dispositionMatch?.[1] || filenameMatch?.[1] || 'attachment'
|
||||
const mimeType = contentTypeMatch?.[1]?.trim() || 'application/octet-stream'
|
||||
|
||||
const dataMatch = part.match(/\r?\n\r?\n([\s\S]*?)$/i)
|
||||
if (dataMatch) {
|
||||
const data = dataMatch[1].trim()
|
||||
|
||||
if (lowerPart.includes('base64')) {
|
||||
try {
|
||||
const buffer = Buffer.from(data.replace(/\s/g, ''), 'base64')
|
||||
attachments.push({
|
||||
name: filename,
|
||||
data: buffer,
|
||||
mimeType,
|
||||
size: buffer.length,
|
||||
})
|
||||
} catch {
|
||||
// Skip if decode fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a body structure contains attachments by examining disposition
|
||||
*/
|
||||
function hasAttachmentsInBodyStructure(structure: FetchMessageObject['bodyStructure']): boolean {
|
||||
if (!structure) return false
|
||||
|
||||
if (structure.disposition === 'attachment') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (structure.disposition === 'inline' && structure.dispositionParameters?.filename) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (structure.childNodes && Array.isArray(structure.childNodes)) {
|
||||
return structure.childNodes.some((child) => hasAttachmentsInBodyStructure(child))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function processEmails(
|
||||
emails: Array<{
|
||||
uid: number
|
||||
mailboxPath: string
|
||||
envelope: FetchMessageObject['envelope']
|
||||
bodyStructure: FetchMessageObject['bodyStructure']
|
||||
source?: Buffer
|
||||
}>,
|
||||
webhookData: WebhookRecord,
|
||||
config: ImapWebhookConfig,
|
||||
requestId: string
|
||||
) {
|
||||
let processedCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: config.host,
|
||||
port: config.port || 993,
|
||||
secure: config.secure ?? true,
|
||||
auth: {
|
||||
user: config.username,
|
||||
pass: config.password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: config.rejectUnauthorized ?? true,
|
||||
},
|
||||
logger: false,
|
||||
})
|
||||
|
||||
let currentOpenMailbox: string | null = null
|
||||
const lockState: { lock: MailboxLockObject | null } = { lock: null }
|
||||
|
||||
try {
|
||||
if (config.markAsRead) {
|
||||
await client.connect()
|
||||
}
|
||||
|
||||
for (const email of emails) {
|
||||
try {
|
||||
await pollingIdempotency.executeWithIdempotency(
|
||||
'imap',
|
||||
`${webhookData.id}:${email.mailboxPath}:${email.uid}`,
|
||||
async () => {
|
||||
const envelope = email.envelope
|
||||
|
||||
const { text: bodyText, html: bodyHtml } = email.source
|
||||
? extractTextFromSource(email.source)
|
||||
: { text: '', html: '' }
|
||||
|
||||
let attachments: ImapAttachment[] = []
|
||||
const hasAttachments = hasAttachmentsInBodyStructure(email.bodyStructure)
|
||||
|
||||
if (config.includeAttachments && hasAttachments && email.source) {
|
||||
attachments = extractAttachmentsFromSource(email.source, email.bodyStructure)
|
||||
}
|
||||
|
||||
const simplifiedEmail: SimplifiedImapEmail = {
|
||||
uid: String(email.uid),
|
||||
messageId: envelope?.messageId || '',
|
||||
subject: envelope?.subject || '[No Subject]',
|
||||
from: parseEmailAddress(envelope?.from),
|
||||
to: parseEmailAddress(envelope?.to),
|
||||
cc: parseEmailAddress(envelope?.cc),
|
||||
date: envelope?.date ? new Date(envelope.date).toISOString() : null,
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
mailbox: email.mailboxPath,
|
||||
hasAttachments,
|
||||
attachments,
|
||||
}
|
||||
|
||||
const payload: ImapWebhookPayload = {
|
||||
email: simplifiedEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Secret': '',
|
||||
'User-Agent': 'Sim/1.0',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(
|
||||
`[${requestId}] Failed to trigger webhook for email ${email.uid}:`,
|
||||
response.status,
|
||||
errorText
|
||||
)
|
||||
throw new Error(`Webhook request failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
if (config.markAsRead) {
|
||||
try {
|
||||
if (currentOpenMailbox !== email.mailboxPath) {
|
||||
if (lockState.lock) {
|
||||
lockState.lock.release()
|
||||
lockState.lock = null
|
||||
}
|
||||
lockState.lock = await client.getMailboxLock(email.mailboxPath)
|
||||
currentOpenMailbox = email.mailboxPath
|
||||
}
|
||||
await client.messageFlagsAdd({ uid: email.uid }, ['\\Seen'], { uid: true })
|
||||
} catch (flagError) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to mark message ${email.uid} as read:`,
|
||||
flagError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
emailUid: email.uid,
|
||||
webhookStatus: response.status,
|
||||
processed: true,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully processed email ${email.uid} from ${email.mailboxPath} for webhook ${webhookData.id}`
|
||||
)
|
||||
processedCount++
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Error processing email ${email.uid}:`, errorMessage)
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (config.markAsRead) {
|
||||
try {
|
||||
if (lockState.lock) {
|
||||
lockState.lock.release()
|
||||
}
|
||||
await client.logout()
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { processedCount, failedCount }
|
||||
}
|
||||
|
||||
async function updateWebhookLastProcessedUids(
|
||||
webhookId: string,
|
||||
uidByMailbox: Record<string, number>,
|
||||
timestamp: string
|
||||
) {
|
||||
const result = await db.select().from(webhook).where(eq(webhook.id, webhookId))
|
||||
const existingConfig = (result[0]?.providerConfig as Record<string, any>) || {}
|
||||
|
||||
const existingUidByMailbox = existingConfig.lastProcessedUidByMailbox || {}
|
||||
const mergedUidByMailbox = { ...existingUidByMailbox }
|
||||
|
||||
for (const [mailbox, uid] of Object.entries(uidByMailbox)) {
|
||||
mergedUidByMailbox[mailbox] = Math.max(uid, mergedUidByMailbox[mailbox] || 0)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: {
|
||||
...existingConfig,
|
||||
lastProcessedUidByMailbox: mergedUidByMailbox,
|
||||
lastCheckedTimestamp: timestamp,
|
||||
} as any,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
}
|
||||
@@ -276,18 +276,20 @@ export async function pollOutlookWebhooks() {
|
||||
}
|
||||
|
||||
for (const webhookData of activeWebhooks) {
|
||||
const promise = enqueue(webhookData)
|
||||
.then(() => {})
|
||||
const promise: Promise<void> = enqueue(webhookData)
|
||||
.catch((err) => {
|
||||
logger.error('Unexpected error in webhook processing:', err)
|
||||
failureCount++
|
||||
})
|
||||
.finally(() => {
|
||||
const idx = running.indexOf(promise)
|
||||
if (idx !== -1) running.splice(idx, 1)
|
||||
})
|
||||
|
||||
running.push(promise)
|
||||
|
||||
if (running.length >= CONCURRENCY) {
|
||||
const completedIdx = await Promise.race(running.map((p, i) => p.then(() => i)))
|
||||
running.splice(completedIdx, 1)
|
||||
await Promise.race(running)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -869,6 +869,39 @@ export async function formatWebhookInput(
|
||||
return body
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'imap') {
|
||||
if (body && typeof body === 'object' && 'email' in body) {
|
||||
const email = body.email as Record<string, any>
|
||||
return {
|
||||
messageId: email?.messageId,
|
||||
subject: email?.subject,
|
||||
from: email?.from,
|
||||
to: email?.to,
|
||||
cc: email?.cc,
|
||||
date: email?.date,
|
||||
bodyText: email?.bodyText,
|
||||
bodyHtml: email?.bodyHtml,
|
||||
mailbox: email?.mailbox,
|
||||
hasAttachments: email?.hasAttachments,
|
||||
attachments: email?.attachments,
|
||||
email,
|
||||
timestamp: body.timestamp,
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'imap',
|
||||
path: foundWebhook.path,
|
||||
providerConfig: foundWebhook.providerConfig,
|
||||
payload: body,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
method: request.method,
|
||||
},
|
||||
},
|
||||
workflowId: foundWorkflow.id,
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'hubspot') {
|
||||
const events = Array.isArray(body) ? body : [body]
|
||||
const event = events[0]
|
||||
@@ -2559,6 +2592,54 @@ export async function configureRssPolling(webhookData: any, requestId: string):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure IMAP polling for a webhook
|
||||
*/
|
||||
export async function configureImapPolling(webhookData: any, requestId: string): Promise<boolean> {
|
||||
const logger = createLogger('ImapWebhookSetup')
|
||||
logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`)
|
||||
|
||||
try {
|
||||
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
|
||||
const now = new Date()
|
||||
|
||||
if (!providerConfig.host || !providerConfig.username || !providerConfig.password) {
|
||||
logger.error(
|
||||
`[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: {
|
||||
...providerConfig,
|
||||
port: providerConfig.port || '993',
|
||||
secure: providerConfig.secure !== false,
|
||||
rejectUnauthorized: providerConfig.rejectUnauthorized !== false,
|
||||
mailbox: providerConfig.mailbox || 'INBOX',
|
||||
searchCriteria: providerConfig.searchCriteria || 'UNSEEN',
|
||||
markAsRead: providerConfig.markAsRead || false,
|
||||
includeAttachments: providerConfig.includeAttachments !== false,
|
||||
lastCheckedTimestamp: now.toISOString(),
|
||||
setupCompleted: true,
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(webhook.id, webhookData.id))
|
||||
|
||||
logger.info(`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Failed to configure IMAP polling`, {
|
||||
webhookId: webhookData.id,
|
||||
error: error.message,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined {
|
||||
if (!twiml) {
|
||||
return twiml
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
import {
|
||||
normalizedStringify,
|
||||
normalizeEdge,
|
||||
@@ -103,11 +103,13 @@ export function hasWorkflowChanged(
|
||||
subBlocks: undefined,
|
||||
}
|
||||
|
||||
// Get all subBlock IDs from both states, excluding runtime metadata
|
||||
// Get all subBlock IDs from both states, excluding runtime metadata and UI-only elements
|
||||
const allSubBlockIds = [
|
||||
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(deployedSubBlocks)]),
|
||||
]
|
||||
.filter((id) => !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id))
|
||||
.filter(
|
||||
(id) => !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id) && !SYSTEM_SUBBLOCK_IDS.includes(id)
|
||||
)
|
||||
.sort()
|
||||
|
||||
// Normalize and compare each subBlock
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"groq-sdk": "^0.15.0",
|
||||
"html-to-image": "1.11.13",
|
||||
"html-to-text": "^9.0.5",
|
||||
"imapflow": "1.2.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"ioredis": "^5.6.0",
|
||||
"isolated-vm": "6.0.2",
|
||||
@@ -126,9 +127,9 @@
|
||||
"onedollarstats": "0.0.10",
|
||||
"openai": "^4.91.1",
|
||||
"papaparse": "5.5.3",
|
||||
"postgres": "^3.4.5",
|
||||
"posthog-js": "1.268.9",
|
||||
"posthog-node": "5.9.2",
|
||||
"postgres": "^3.4.5",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.1",
|
||||
"react-colorful": "5.6.1",
|
||||
|
||||
@@ -85,6 +85,30 @@ export const gmailPollingTrigger: TriggerConfig = {
|
||||
'Optional Gmail search query to filter emails. Use the same format as Gmail search box (e.g., "subject:invoice", "from:boss@company.com", "has:attachment"). Leave empty to search all emails.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert in Gmail search syntax. Generate Gmail search queries based on user descriptions.
|
||||
|
||||
Gmail search operators include:
|
||||
- from: / to: / cc: / bcc: - Filter by sender/recipient
|
||||
- subject: - Search in subject line
|
||||
- has:attachment - Emails with attachments
|
||||
- filename: - Search attachment filenames
|
||||
- is:unread / is:read / is:starred
|
||||
- after: / before: / older: / newer: - Date filters (YYYY/MM/DD)
|
||||
- label: - Filter by label
|
||||
- in:inbox / in:spam / in:trash
|
||||
- larger: / smaller: - Size filters (e.g., 10M, 1K)
|
||||
- OR / AND / - (NOT) - Boolean operators
|
||||
- "exact phrase" - Exact match
|
||||
- ( ) - Grouping
|
||||
|
||||
Current query: {context}
|
||||
|
||||
Return ONLY the Gmail search query, no explanations or markdown.`,
|
||||
placeholder: 'Describe what emails you want to filter...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'markAsRead',
|
||||
|
||||
1
apps/sim/triggers/imap/index.ts
Normal file
1
apps/sim/triggers/imap/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { imapPollingTrigger } from '@/triggers/imap/poller'
|
||||
287
apps/sim/triggers/imap/poller.ts
Normal file
287
apps/sim/triggers/imap/poller.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { MailServerIcon } from '@/components/icons'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
const logger = createLogger('ImapPollingTrigger')
|
||||
|
||||
export const imapPollingTrigger: TriggerConfig = {
|
||||
id: 'imap_poller',
|
||||
name: 'IMAP Email Trigger',
|
||||
provider: 'imap',
|
||||
description: 'Triggers when new emails are received via IMAP (works with any email provider)',
|
||||
version: '1.0.0',
|
||||
icon: MailServerIcon,
|
||||
|
||||
subBlocks: [
|
||||
// Connection settings
|
||||
{
|
||||
id: 'host',
|
||||
title: 'IMAP Server',
|
||||
type: 'short-input',
|
||||
placeholder: 'imap.example.com',
|
||||
description: 'IMAP server hostname (e.g., imap.gmail.com, outlook.office365.com)',
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'port',
|
||||
title: 'Port',
|
||||
type: 'short-input',
|
||||
placeholder: '993',
|
||||
description: 'IMAP port (993 for SSL/TLS, 143 for STARTTLS)',
|
||||
defaultValue: '993',
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'secure',
|
||||
title: 'Use SSL/TLS',
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
description: 'Enable SSL/TLS encryption (recommended for port 993)',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'rejectUnauthorized',
|
||||
title: 'Verify TLS Certificate',
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
description: 'Verify server TLS certificate. Disable for self-signed certificates.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
// Authentication
|
||||
{
|
||||
id: 'username',
|
||||
title: 'Username',
|
||||
type: 'short-input',
|
||||
placeholder: 'user@example.com',
|
||||
description: 'Email address or username for authentication',
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
title: 'Password',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'App password or email password',
|
||||
description: 'Password or app-specific password (for Gmail, use an App Password)',
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
// Mailbox selection
|
||||
{
|
||||
id: 'mailbox',
|
||||
title: 'Mailboxes to Monitor',
|
||||
type: 'dropdown',
|
||||
multiSelect: true,
|
||||
placeholder: 'Select mailboxes to monitor',
|
||||
description:
|
||||
'Choose which mailbox/folder(s) to monitor for new emails. Leave empty to monitor INBOX.',
|
||||
required: false,
|
||||
options: [],
|
||||
fetchOptions: async (blockId: string, _subBlockId: string) => {
|
||||
const store = useSubBlockStore.getState()
|
||||
const host = store.getValue(blockId, 'host') as string | null
|
||||
const port = store.getValue(blockId, 'port') as string | null
|
||||
const secure = store.getValue(blockId, 'secure') as boolean | null
|
||||
const rejectUnauthorized = store.getValue(blockId, 'rejectUnauthorized') as boolean | null
|
||||
const username = store.getValue(blockId, 'username') as string | null
|
||||
const password = store.getValue(blockId, 'password') as string | null
|
||||
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('Please enter IMAP server, username, and password first')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/imap/mailboxes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
host,
|
||||
port: port ? Number.parseInt(port, 10) : 993,
|
||||
secure: secure ?? true,
|
||||
rejectUnauthorized: rejectUnauthorized ?? true,
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to fetch mailboxes')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.mailboxes && Array.isArray(data.mailboxes)) {
|
||||
return data.mailboxes.map((mailbox: { path: string; name: string }) => ({
|
||||
id: mailbox.path,
|
||||
label: mailbox.name,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
logger.error('Error fetching IMAP mailboxes:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
dependsOn: ['host', 'port', 'secure', 'rejectUnauthorized', 'username', 'password'],
|
||||
mode: 'trigger',
|
||||
},
|
||||
// Email filtering
|
||||
{
|
||||
id: 'searchCriteria',
|
||||
title: 'Search Criteria',
|
||||
type: 'code',
|
||||
placeholder: '{ "unseen": true }',
|
||||
description: 'ImapFlow search criteria as JSON object. Default: unseen messages only.',
|
||||
defaultValue: '{ "unseen": true }',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
generationType: 'json-object',
|
||||
prompt: `Generate ImapFlow search criteria as a JSON object based on the user's description.
|
||||
|
||||
Available properties (all are optional, combine as needed):
|
||||
- "unseen": true - Unread messages
|
||||
- "seen": true - Read messages
|
||||
- "flagged": true - Starred/flagged messages
|
||||
- "answered": true - Replied messages
|
||||
- "deleted": true - Deleted messages
|
||||
- "draft": true - Draft messages
|
||||
- "from": "sender@example.com" - From address contains
|
||||
- "to": "recipient@example.com" - To address contains
|
||||
- "cc": "cc@example.com" - CC address contains
|
||||
- "subject": "keyword" - Subject contains
|
||||
- "body": "text" - Body contains
|
||||
- "text": "search" - Headers or body contains
|
||||
- "since": "2024-01-01" - Emails since date (ISO format)
|
||||
- "before": "2024-12-31" - Emails before date
|
||||
- "larger": 10240 - Larger than N bytes
|
||||
- "smaller": 1048576 - Smaller than N bytes
|
||||
- "header": { "X-Priority": "1" } - Custom header search
|
||||
- "or": [{ "from": "a@x.com" }, { "from": "b@x.com" }] - OR conditions
|
||||
- "not": { "from": "spam@x.com" } - Negate condition
|
||||
|
||||
Multiple properties are combined with AND.
|
||||
|
||||
Examples:
|
||||
- Unread from boss: { "unseen": true, "from": "boss@company.com" }
|
||||
- From Alice or Bob: { "or": [{ "from": "alice@x.com" }, { "from": "bob@x.com" }] }
|
||||
- Recent with keyword: { "since": "2024-01-01", "subject": "report" }
|
||||
- Exclude spam: { "unseen": true, "not": { "from": "newsletter@x.com" } }
|
||||
|
||||
Current criteria: {context}
|
||||
|
||||
Return ONLY valid JSON, no explanations or markdown.`,
|
||||
placeholder: 'Describe what emails you want to filter...',
|
||||
},
|
||||
},
|
||||
// Processing options
|
||||
{
|
||||
id: 'markAsRead',
|
||||
title: 'Mark as Read',
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
description: 'Automatically mark emails as read (SEEN) after processing',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'includeAttachments',
|
||||
title: 'Include Attachments',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
description: 'Download and include email attachments in the trigger payload',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
// Instructions
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
hideFromPreview: true,
|
||||
type: 'text',
|
||||
defaultValue: [
|
||||
'Enter your IMAP server details (host, port, SSL settings)',
|
||||
'Enter your email credentials (username and password)',
|
||||
'For Gmail: Use an <a href="https://support.google.com/accounts/answer/185833" target="_blank">App Password</a> instead of your regular password',
|
||||
'Select the mailbox to monitor (INBOX is most common)',
|
||||
'Optionally configure search criteria and processing options',
|
||||
'The system will automatically check for new emails and trigger your workflow',
|
||||
]
|
||||
.map(
|
||||
(instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
)
|
||||
.join(''),
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'triggerSave',
|
||||
title: '',
|
||||
type: 'trigger-save',
|
||||
hideFromPreview: true,
|
||||
mode: 'trigger',
|
||||
triggerId: 'imap_poller',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: {
|
||||
email: {
|
||||
messageId: {
|
||||
type: 'string',
|
||||
description: 'RFC Message-ID header',
|
||||
},
|
||||
subject: {
|
||||
type: 'string',
|
||||
description: 'Email subject line',
|
||||
},
|
||||
from: {
|
||||
type: 'string',
|
||||
description: 'Sender email address',
|
||||
},
|
||||
to: {
|
||||
type: 'string',
|
||||
description: 'Recipient email address',
|
||||
},
|
||||
cc: {
|
||||
type: 'string',
|
||||
description: 'CC recipients',
|
||||
},
|
||||
date: {
|
||||
type: 'string',
|
||||
description: 'Email date in ISO format',
|
||||
},
|
||||
bodyText: {
|
||||
type: 'string',
|
||||
description: 'Plain text email body',
|
||||
},
|
||||
bodyHtml: {
|
||||
type: 'string',
|
||||
description: 'HTML email body',
|
||||
},
|
||||
mailbox: {
|
||||
type: 'string',
|
||||
description: 'Mailbox/folder where email was received',
|
||||
},
|
||||
hasAttachments: {
|
||||
type: 'boolean',
|
||||
description: 'Whether email has attachments',
|
||||
},
|
||||
attachments: {
|
||||
type: 'file[]',
|
||||
description: 'Array of email attachments as files (if includeAttachments is enabled)',
|
||||
},
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
description: 'Event timestamp',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
hubspotTicketDeletedTrigger,
|
||||
hubspotTicketPropertyChangedTrigger,
|
||||
} from '@/triggers/hubspot'
|
||||
import { imapPollingTrigger } from '@/triggers/imap'
|
||||
import {
|
||||
jiraIssueCommentedTrigger,
|
||||
jiraIssueCreatedTrigger,
|
||||
@@ -183,4 +184,5 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
hubspot_ticket_created: hubspotTicketCreatedTrigger,
|
||||
hubspot_ticket_deleted: hubspotTicketDeletedTrigger,
|
||||
hubspot_ticket_property_changed: hubspotTicketPropertyChangedTrigger,
|
||||
imap_poller: imapPollingTrigger,
|
||||
}
|
||||
|
||||
21
bun.lock
21
bun.lock
@@ -132,6 +132,7 @@
|
||||
"groq-sdk": "^0.15.0",
|
||||
"html-to-image": "1.11.13",
|
||||
"html-to-text": "^9.0.5",
|
||||
"imapflow": "1.2.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"ioredis": "^5.6.0",
|
||||
"isolated-vm": "6.0.2",
|
||||
@@ -1556,6 +1557,8 @@
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||
|
||||
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
@@ -1998,6 +2001,8 @@
|
||||
|
||||
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
|
||||
|
||||
"encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
@@ -2292,6 +2297,8 @@
|
||||
|
||||
"image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
|
||||
|
||||
"imapflow": ["imapflow@1.2.4", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.1", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "7.0.12", "pino": "10.1.0", "socks": "2.8.7" } }, "sha512-X/eRQeje33uZycfopjwoQKKbya+bBIaqpviOFxhPOD24DXU2hMfXwYe9e8j1+ADwFVgTvKq4G2/ljjZK3Y8mvg=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
|
||||
@@ -2424,6 +2431,12 @@
|
||||
|
||||
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||
|
||||
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
|
||||
|
||||
"libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="],
|
||||
|
||||
"libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
|
||||
@@ -3958,6 +3971,12 @@
|
||||
|
||||
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
|
||||
|
||||
"imapflow/iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
|
||||
|
||||
"imapflow/nodemailer": ["nodemailer@7.0.12", "", {}, "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA=="],
|
||||
|
||||
"imapflow/pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="],
|
||||
|
||||
"inquirer/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
|
||||
@@ -4392,6 +4411,8 @@
|
||||
|
||||
"groq-sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"imapflow/pino/thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
|
||||
"inquirer/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"inquirer/ora/is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
|
||||
|
||||
@@ -687,6 +687,15 @@ cronjobs:
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
imapWebhookPoll:
|
||||
enabled: true
|
||||
name: imap-webhook-poll
|
||||
schedule: "*/1 * * * *"
|
||||
path: "/api/webhooks/poll/imap"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
renewSubscriptions:
|
||||
enabled: true
|
||||
name: renew-subscriptions
|
||||
|
||||
Reference in New Issue
Block a user