From a2c53c66d04b51c1d40e72ecb708b9a504a9c439 Mon Sep 17 00:00:00 2001 From: Manthan Date: Fri, 6 Feb 2026 23:07:17 -0800 Subject: [PATCH 1/2] Enhance AI agent tools and add email/calendar integrations --- docs/agents.html | 8 +- docs/integrations.html | 96 ++++- src/engine/scheduler.ts | 22 +- src/mcp/types.ts | 18 + src/plugins/builtin/ai/index.ts | 189 ++++++++- src/plugins/builtin/calendar/index.ts | 529 ++++++++++++++++++++++++++ src/plugins/builtin/email/index.ts | 378 +++++++++++++++++- src/plugins/loader.ts | 2 + src/types/index.ts | 18 + 9 files changed, 1212 insertions(+), 48 deletions(-) create mode 100644 src/plugins/builtin/calendar/index.ts diff --git a/docs/agents.html b/docs/agents.html index 51f21fd..aa376b1 100644 --- a/docs/agents.html +++ b/docs/agents.html @@ -206,8 +206,12 @@

Built-in Tools

Search the web (requires API key) - run_workflow - Execute another Weavr workflow + web_fetch + Fetch and extract content from a URL + + + shell_exec + Execute local shell commands (use with caution) diff --git a/docs/integrations.html b/docs/integrations.html index e220f11..7a91ddb 100644 --- a/docs/integrations.html +++ b/docs/integrations.html @@ -41,6 +41,7 @@

Integrations

Linear HTTP / Webhooks Email + Calendar Cron Schedules MCP Servers @@ -350,10 +351,10 @@

HTTP Request Action

Email

-

Send emails via SMTP.

+

Send emails via Resend API or SMTP, and receive inbound email webhooks.

Setup

-

Configure SMTP in ~/.weavr/config.yaml:

+

Configure SMTP in ~/.weavr/config.yaml or set environment variables:

@@ -364,10 +365,18 @@

Setup

smtp: host: smtp.example.com port: 587 - secure: true - auth: - user: your-email@example.com - pass: your-password + secure: false + user: your-email@example.com + pass: your-password + authMethod: login +
+ +
+ Environment variables: + SMTP_HOST, SMTP_PORT, SMTP_SECURE, + SMTP_USER, SMTP_PASS, SMTP_AUTH_METHOD, + EMAIL_API_KEY or RESEND_API_KEY, + EMAIL_FROM

Send Email

@@ -382,7 +391,80 @@

Send Email

with: to: recipient@example.com subject: "Workflow Complete" - body: "Your workflow finished successfully." + text: "Your workflow finished successfully." + provider: smtp # auto | smtp | api +
+ +

Inbound Email Webhook

+

Configure your email provider to POST inbound email payloads to /webhook/email (or a custom path).

+
+
+
+
+
+
+
trigger:
+  type: email.inbound
+  with:
+    path: email
+    provider: generic
+
+
+ +
+

Calendar

+

Integrate CalDAV calendars (Nextcloud, iCloud, Fastmail, and more).

+ +

List Events

+
+
+
+
+
+
+
- id: list-events
+  action: calendar.list_events
+  with:
+    calendarUrl: "https://cal.example.com/dav/calendars/user/default/"
+    username: "user@example.com"
+    password: "app-password"
+    from: "2026-02-07T00:00:00Z"
+    to: "2026-02-14T00:00:00Z"
+
+ +

Create Event

+
+
+
+
+
+
+
- id: create-event
+  action: calendar.create_event
+  with:
+    calendarUrl: "https://cal.example.com/dav/calendars/user/default/"
+    username: "user@example.com"
+    password: "app-password"
+    summary: "Weekly Sync"
+    start: "2026-02-10T15:00:00Z"
+    end: "2026-02-10T15:30:00Z"
+
+ +

Upcoming Event Trigger

+
+
+
+
+
+
+
trigger:
+  type: calendar.event_upcoming
+  with:
+    calendarUrl: "https://cal.example.com/dav/calendars/user/default/"
+    username: "user@example.com"
+    password: "app-password"
+    windowMinutes: 60
+    pollIntervalSeconds: 60
diff --git a/src/engine/scheduler.ts b/src/engine/scheduler.ts index 2ed8a3b..24ec84d 100644 --- a/src/engine/scheduler.ts +++ b/src/engine/scheduler.ts @@ -7,6 +7,8 @@ import { parse as parseYaml } from 'yaml'; import { randomUUID } from 'node:crypto'; import type { Workflow, WorkflowRun, WeavrConfig } from '../types/index.js'; import { parser } from './parser.js'; + +const WEBHOOK_TRIGGER_TYPES = new Set(['http.webhook', 'email.inbound']); import { WorkflowExecutor } from './executor.js'; import type { PluginRegistry } from '../plugins/sdk/registry.js'; import { TriggerManager } from './trigger-manager.js'; @@ -280,9 +282,11 @@ export class TriggerScheduler { for (const scheduled of this.scheduledWorkflows.values()) { if (scheduled.status !== 'active') continue; - if (scheduled.triggerType !== 'http.webhook') continue; + if (!WEBHOOK_TRIGGER_TYPES.has(scheduled.triggerType)) continue; - const webhookPath = scheduled.triggerConfig.path as string; + const defaultPath = scheduled.triggerType === 'email.inbound' ? 'email' : undefined; + const webhookPath = (scheduled.triggerConfig.path as string | undefined) ?? defaultPath; + if (!webhookPath) continue; if (webhookPath === path || webhookPath === `/${path}` || `/${webhookPath}` === path) { try { const runId = randomUUID(); @@ -290,10 +294,18 @@ export class TriggerScheduler { runIds.push(runId); this.events.onWorkflowTriggered?.(scheduled.name, runId); + const triggerPayload = scheduled.triggerType === 'email.inbound' + ? { + type: 'email', + path, + provider: scheduled.triggerConfig.provider as string | undefined, + data, + } + : { type: 'webhook', path, data }; this.enqueueRun( scheduled.name, - 'http.webhook', - { type: 'webhook', path, data }, + scheduled.triggerType, + triggerPayload, scheduled.workflowContent, runId ); @@ -459,7 +471,7 @@ export class TriggerScheduler { console.error(`[scheduler] Invalid cron schedule for ${name}:`, err); } } - } else if (triggerType !== 'http.webhook') { + } else if (!WEBHOOK_TRIGGER_TYPES.has(triggerType)) { const success = await this.triggerManager.setupTrigger(name, triggerType, triggerConfig, yamlContent, id); if (success) { console.log(`[scheduler] Custom trigger set up: ${name} (${triggerType})`); diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 71f6b06..5245b66 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -152,6 +152,24 @@ export interface WeavrConfig { scheduler?: { dbPath?: string; }; + email?: { + smtp?: { + host: string; + port?: number; + secure?: boolean; + user?: string; + pass?: string; + authMethod?: 'login' | 'plain'; + }; + }; + calendar?: { + caldav?: { + calendarUrl: string; + username?: string; + password?: string; + bearerToken?: string; + }; + }; mcp?: { servers?: Record; }; diff --git a/src/plugins/builtin/ai/index.ts b/src/plugins/builtin/ai/index.ts index 65b0747..31f249f 100644 --- a/src/plugins/builtin/ai/index.ts +++ b/src/plugins/builtin/ai/index.ts @@ -1,6 +1,6 @@ import { definePlugin, defineAction } from '../../sdk/types.js'; import { z } from 'zod'; -import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; @@ -121,6 +121,20 @@ function formatMemoryContext(memory: MemoryContext | undefined, selection: unkno return sections.join('\n\n'); } +function normalizeToolList(tools: unknown): string[] { + if (tools === undefined || tools === null) return []; + if (Array.isArray(tools)) { + return tools.map((t) => String(t).trim()).filter(Boolean); + } + if (typeof tools === 'string') { + return tools + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + } + return []; +} + // Helper to sleep for a given duration function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -861,9 +875,16 @@ Return ONLY the category name, nothing else.`; const task = memoryContext ? `${rawTask}\n\n## Memory Context\n${memoryContext}` : rawTask; - const tools = (ctx.config.tools as string[] | undefined) ?? ['web_search', 'web_fetch', 'shell']; + const toolsConfig = ctx.config.tools; + const normalizedTools = normalizeToolList(toolsConfig); + const tools = toolsConfig === undefined || toolsConfig === null + ? ['web_search', 'web_fetch', 'http_request', 'filesystem', 'shell'] + : normalizedTools; const maxIterations = (ctx.config.maxIterations as number) ?? 10; const systemPrompt = ctx.config.system as string | undefined; + const modelOverride = ctx.config.model as string | undefined; + const maxTokensOverride = ctx.config.maxTokens as number | undefined; + const temperatureOverride = ctx.config.temperature as number | undefined; const anthropicKey = getAnthropicKey(ctx); const openaiKey = getOpenAIKey(ctx); @@ -889,13 +910,27 @@ Return ONLY the category name, nothing else.`; const failedTools: Map = new Map(); // Build available tools based on configuration + const toolSet = new Set( + tools + .map((tool) => tool.toLowerCase()) + .flatMap((tool) => (tool === 'all' ? ['web_search', 'web_fetch', 'http_request', 'filesystem', 'shell'] : [tool])) + ); + + const wantsReadFile = toolSet.has('filesystem') || toolSet.has('read_file'); + const wantsWriteFile = toolSet.has('filesystem') || toolSet.has('write_file'); + const wantsListDirectory = toolSet.has('filesystem') || toolSet.has('list_directory'); + const wantsHttp = toolSet.has('http') || toolSet.has('http_request'); + const wantsShell = toolSet.has('shell') || toolSet.has('shell_exec'); + const wantsWebSearch = toolSet.has('web_search'); + const wantsWebFetch = toolSet.has('web_fetch'); + const availableTools: Array<{ name: string; description: string; input_schema: Record; }> = []; - if (tools.includes('web_search')) { + if (wantsWebSearch) { availableTools.push({ name: 'web_search', description: 'Search the web for information', @@ -909,7 +944,7 @@ Return ONLY the category name, nothing else.`; }); } - if (tools.includes('web_fetch')) { + if (wantsWebFetch) { availableTools.push({ name: 'web_fetch', description: 'Fetch content from a URL', @@ -923,7 +958,7 @@ Return ONLY the category name, nothing else.`; }); } - if (tools.includes('shell')) { + if (wantsShell) { availableTools.push({ name: 'shell_exec', description: 'Execute a shell command (use with caution)', @@ -937,7 +972,7 @@ Return ONLY the category name, nothing else.`; }); } - if (tools.includes('filesystem')) { + if (wantsReadFile) { availableTools.push({ name: 'read_file', description: 'Read contents of a file', @@ -949,6 +984,9 @@ Return ONLY the category name, nothing else.`; required: ['path'], }, }); + } + + if (wantsWriteFile) { availableTools.push({ name: 'write_file', description: 'Write content to a file', @@ -963,6 +1001,40 @@ Return ONLY the category name, nothing else.`; }); } + if (wantsListDirectory) { + availableTools.push({ + name: 'list_directory', + description: 'List files in a directory', + input_schema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Directory path to list' }, + pattern: { type: 'string', description: 'Optional regex pattern to filter entries' }, + recursive: { type: 'boolean', description: 'Recursively list subdirectories' }, + }, + required: ['path'], + }, + }); + } + + if (wantsHttp) { + availableTools.push({ + name: 'http_request', + description: 'Make an HTTP request', + input_schema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Request URL' }, + method: { type: 'string', description: 'HTTP method (GET, POST, PUT, PATCH, DELETE)' }, + headers: { type: 'object', description: 'Request headers' }, + body: { type: 'object', description: 'JSON body payload' }, + timeout: { type: 'number', description: 'Timeout in ms' }, + }, + required: ['url'], + }, + }); + } + // Add MCP tools to available tools (they get routed through executeTool) if (mcpManager && mcpManager.getServers().size > 0) { try { @@ -993,7 +1065,8 @@ Return ONLY the category name, nothing else.`; } // Check for too-short results that indicate failure - if (result.length < 50 && !result.includes('successfully')) { + const shortResultSafeTools = new Set(['shell_exec', 'read_file', 'write_file', 'list_directory', 'http_request']); + if (result.length < 50 && !result.includes('successfully') && !shortResultSafeTools.has(_toolName)) { return { valid: false, feedback: `${result}\n\n[VALIDATION: Result too short. Try an alternative approach.]`, @@ -1291,6 +1364,86 @@ Try web_fetch with specific URLs: } } + case 'list_directory': { + try { + const targetPath = String(input.path); + const recursive = Boolean(input.recursive); + const pattern = typeof input.pattern === 'string' && input.pattern.length > 0 + ? new RegExp(input.pattern) + : null; + + const entries: Array<{ path: string; name: string; type: 'file' | 'directory'; size?: number }> = []; + const stack = [targetPath]; + + while (stack.length > 0) { + const current = stack.pop() as string; + const dirEntries = readdirSync(current, { withFileTypes: true }); + for (const entry of dirEntries) { + if (pattern && !pattern.test(entry.name)) continue; + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + entries.push({ path: fullPath, name: entry.name, type: 'directory' }); + if (recursive) stack.push(fullPath); + } else if (entry.isFile()) { + const stats = statSync(fullPath); + entries.push({ path: fullPath, name: entry.name, type: 'file', size: stats.size }); + } + } + } + + return JSON.stringify({ path: targetPath, count: entries.length, entries }, null, 2); + } catch (err) { + return `List failed: ${String(err)}`; + } + } + + case 'http_request': { + try { + const url = String(input.url); + const method = String(input.method ?? 'GET').toUpperCase(); + const timeout = typeof input.timeout === 'number' ? input.timeout : 30000; + let headers: Record = {}; + if (typeof input.headers === 'string') { + try { + headers = JSON.parse(input.headers) as Record; + } catch { + headers = {}; + } + } else if (input.headers && typeof input.headers === 'object') { + headers = input.headers as Record; + } + const body = input.body; + + const response = await fetchWithTimeout(url, { + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }, timeout, 2, ctx.log); + + const contentType = response.headers.get('content-type') ?? ''; + let data: string; + if (contentType.includes('application/json')) { + const json = await response.json(); + data = JSON.stringify(json, null, 2); + } else { + data = await response.text(); + } + + const truncated = data.length > 12000 ? `${data.slice(0, 12000)}\n...(truncated)` : data; + return JSON.stringify({ + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: truncated, + }, null, 2); + } catch (err) { + return `HTTP request failed: ${String(err)}`; + } + } + default: { // Try MCP tools as fallback if (mcpManager && mcpManager.getServers().size > 0) { @@ -1348,8 +1501,9 @@ Once you have sufficient information (even if not perfect), synthesize your answ ## Tool Selection Guide - **web_search**: Use first for discovery. Batch multiple queries if related. - **web_fetch**: Use for specific known URLs. Combine with search results. +- **http_request**: Use for API calls or structured HTTP responses. - **shell_exec**: Use for local commands. Check permissions first. -- **read_file/write_file**: Use for local file operations. +- **read_file/write_file/list_directory**: Use for local file operations. ## Efficiency Rules - Aim to complete in 2-3 iterations, not 5+ @@ -1386,6 +1540,7 @@ If a tool returns [FAILED] or [ERROR]: let responseData: Record; if (anthropicKey) { + const model = modelOverride ?? globalConfig.model ?? 'claude-sonnet-4-20250514'; response = await fetchWithTimeout('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { @@ -1394,8 +1549,9 @@ If a tool returns [FAILED] or [ERROR]: 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ - model: globalConfig.model ?? 'claude-sonnet-4-20250514', - max_tokens: 4096, + model, + max_tokens: maxTokensOverride ?? 4096, + ...(temperatureOverride !== undefined ? { temperature: temperatureOverride } : {}), system: finalSystemPrompt, tools: availableTools, messages, @@ -1412,7 +1568,7 @@ If a tool returns [FAILED] or [ERROR]: // Track usage for Anthropic agent calls const usage = responseData.usage as { input_tokens?: number; output_tokens?: number } | undefined; if (usage) { - trackUsage(usage.input_tokens ?? 0, usage.output_tokens ?? 0); + trackUsage(usage.input_tokens ?? 0, usage.output_tokens ?? 0, model); } const content = responseData.content as Array<{ type: string; text?: string; name?: string; input?: unknown; id?: string }>; @@ -1623,6 +1779,7 @@ If a tool returns [FAILED] or [ERROR]: } // Codex API with streaming + const codexModel = modelOverride ?? globalConfig.model ?? 'gpt-4o'; const codexResponse = await fetch('https://chatgpt.com/backend-api/codex/responses', { method: 'POST', headers: { @@ -1630,7 +1787,7 @@ If a tool returns [FAILED] or [ERROR]: 'Authorization': `Bearer ${oauthToken}`, }, body: JSON.stringify({ - model: globalConfig.model ?? 'gpt-4o', + model: codexModel, instructions: finalSystemPrompt, input: codexInput, stream: true, @@ -1741,6 +1898,7 @@ If a tool returns [FAILED] or [ERROR]: } } + const model = modelOverride ?? globalConfig.model ?? 'gpt-4o'; response = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { @@ -1748,8 +1906,9 @@ If a tool returns [FAILED] or [ERROR]: 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ - model: globalConfig.model ?? 'gpt-4o', - max_tokens: 4096, + model, + max_tokens: maxTokensOverride ?? 4096, + ...(temperatureOverride !== undefined ? { temperature: temperatureOverride } : {}), messages: openaiMessages, tools: availableTools.map(t => ({ type: 'function', @@ -1772,7 +1931,7 @@ If a tool returns [FAILED] or [ERROR]: // Track usage for OpenAI agent calls const openaiUsage = responseData.usage as { prompt_tokens?: number; completion_tokens?: number } | undefined; if (openaiUsage) { - trackUsage(openaiUsage.prompt_tokens ?? 0, openaiUsage.completion_tokens ?? 0); + trackUsage(openaiUsage.prompt_tokens ?? 0, openaiUsage.completion_tokens ?? 0, model); } const choice = (responseData.choices as Array<{ diff --git a/src/plugins/builtin/calendar/index.ts b/src/plugins/builtin/calendar/index.ts new file mode 100644 index 0000000..53e6256 --- /dev/null +++ b/src/plugins/builtin/calendar/index.ts @@ -0,0 +1,529 @@ +import { definePlugin, defineAction, defineTrigger } from '../../sdk/types.js'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; + +const CalDavAuthSchema = z.object({ + calendarUrl: z.string().url(), + username: z.string().optional(), + password: z.string().optional(), + bearerToken: z.string().optional(), + headers: z.record(z.string()).optional(), +}); + +const ListEventsSchema = CalDavAuthSchema.extend({ + from: z.string().optional(), + to: z.string().optional(), + limit: z.number().optional(), +}); + +const CreateEventSchema = CalDavAuthSchema.extend({ + uid: z.string().optional(), + summary: z.string(), + description: z.string().optional(), + location: z.string().optional(), + start: z.string(), + end: z.string().optional(), + allDay: z.boolean().optional(), + timezone: z.string().optional(), + organizer: z.string().optional(), + attendees: z.array(z.object({ + email: z.string().email(), + name: z.string().optional(), + rsvp: z.boolean().optional(), + })).optional(), + ifMatch: z.string().optional(), +}); + +const DeleteEventSchema = CalDavAuthSchema.extend({ + uid: z.string(), + ifMatch: z.string().optional(), +}); + +const UpcomingTriggerSchema = CalDavAuthSchema.extend({ + windowMinutes: z.number().default(60), + pollIntervalSeconds: z.number().default(60), + lookbackMinutes: z.number().default(5), +}); + +interface CalendarEvent { + uid?: string; + summary?: string; + description?: string; + location?: string; + start?: string; + end?: string; + allDay?: boolean; + timezone?: string; + href?: string; + etag?: string; +} + +function normalizeCalendarUrl(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + +function buildAuthHeaders(config: z.infer): Record { + if (config.bearerToken) { + return { Authorization: `Bearer ${config.bearerToken}` }; + } + if (config.username && config.password) { + const token = Buffer.from(`${config.username}:${config.password}`, 'utf-8').toString('base64'); + return { Authorization: `Basic ${token}` }; + } + return {}; +} + +function decodeXmlEntities(input: string): string { + return input + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +function extractTag(text: string, tag: string): string | undefined { + const regex = new RegExp(`<[^:>]*:?${tag}[^>]*>([\\s\\S]*?)]*:?${tag}>`, 'i'); + const match = text.match(regex); + if (!match) return undefined; + return decodeXmlEntities(match[1].trim()); +} + +function parseMultiStatus(xml: string): Array<{ href?: string; etag?: string; calendarData?: string }> { + const responses = xml.match(/<[^:>]*:?response[^>]*>[\\s\\S]*?<\/[^:>]*:?response>/gi) ?? []; + return responses.map((response) => { + const href = extractTag(response, 'href'); + const etag = extractTag(response, 'getetag'); + const calendarDataRaw = extractTag(response, 'calendar-data'); + const calendarData = calendarDataRaw + ? calendarDataRaw.replace(/^$/, '') + : undefined; + return { href, etag, calendarData }; + }); +} + +function unfoldIcsLines(ics: string): string[] { + const rawLines = ics + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n'); + + const lines: string[] = []; + for (const line of rawLines) { + if ((line.startsWith(' ') || line.startsWith('\t')) && lines.length > 0) { + lines[lines.length - 1] += line.slice(1); + } else { + lines.push(line); + } + } + + return lines.filter((line) => line.length > 0); +} + +function parseIcsDate(value: string, params: Record): { iso?: string; allDay: boolean; timezone?: string } { + const tzid = params.TZID; + if (params.VALUE === 'DATE' || /^\d{8}$/.test(value)) { + const year = value.slice(0, 4); + const month = value.slice(4, 6); + const day = value.slice(6, 8); + return { iso: `${year}-${month}-${day}`, allDay: true, timezone: tzid }; + } + + if (/Z$/.test(value)) { + const iso = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(9, 11)}:${value.slice(11, 13)}:${value.slice(13, 15)}Z`; + return { iso, allDay: false, timezone: 'UTC' }; + } + + if (/^\d{8}T\d{6}$/.test(value)) { + const iso = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(9, 11)}:${value.slice(11, 13)}:${value.slice(13, 15)}`; + return { iso, allDay: false, timezone: tzid }; + } + + return { iso: value, allDay: false, timezone: tzid }; +} + +function parseIcs(ics: string): CalendarEvent[] { + const lines = unfoldIcsLines(ics); + const events: CalendarEvent[] = []; + let current: CalendarEvent | null = null; + + for (const line of lines) { + if (line === 'BEGIN:VEVENT') { + current = {}; + continue; + } + + if (line === 'END:VEVENT') { + if (current) events.push(current); + current = null; + continue; + } + + if (!current) continue; + + const [left, ...rest] = line.split(':'); + const value = rest.join(':'); + const [name, ...paramParts] = left.split(';'); + const upperName = name.toUpperCase(); + + const params: Record = {}; + for (const part of paramParts) { + const [key, val] = part.split('='); + if (key && val) { + params[key.toUpperCase()] = val; + } + } + + switch (upperName) { + case 'UID': + current.uid = value; + break; + case 'SUMMARY': + current.summary = value; + break; + case 'DESCRIPTION': + current.description = value; + break; + case 'LOCATION': + current.location = value; + break; + case 'DTSTART': { + const parsed = parseIcsDate(value, params); + current.start = parsed.iso; + current.allDay = parsed.allDay; + current.timezone = parsed.timezone; + break; + } + case 'DTEND': { + const parsed = parseIcsDate(value, params); + current.end = parsed.iso; + if (parsed.timezone && !current.timezone) current.timezone = parsed.timezone; + break; + } + default: + break; + } + } + + return events; +} + +function escapeIcsText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/,/g, '\\,') + .replace(/;/g, '\\;'); +} + +function formatIcsDate(value: string, opts: { allDay?: boolean; timezone?: string }): { value: string; params: string[] } { + if (/^\d{8}(T\d{6}Z?)?$/.test(value)) { + return { value, params: [] }; + } + + if (opts.allDay) { + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + const yyyy = date.getUTCFullYear().toString().padStart(4, '0'); + const mm = (date.getUTCMonth() + 1).toString().padStart(2, '0'); + const dd = date.getUTCDate().toString().padStart(2, '0'); + return { value: `${yyyy}${mm}${dd}`, params: ['VALUE=DATE'] }; + } + } + + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + if (opts.timezone && opts.timezone.toUpperCase() !== 'UTC') { + const yyyy = date.getFullYear().toString().padStart(4, '0'); + const mm = (date.getMonth() + 1).toString().padStart(2, '0'); + const dd = date.getDate().toString().padStart(2, '0'); + const hh = date.getHours().toString().padStart(2, '0'); + const mi = date.getMinutes().toString().padStart(2, '0'); + const ss = date.getSeconds().toString().padStart(2, '0'); + return { value: `${yyyy}${mm}${dd}T${hh}${mi}${ss}`, params: [`TZID=${opts.timezone}`] }; + } + + const yyyy = date.getUTCFullYear().toString().padStart(4, '0'); + const mm = (date.getUTCMonth() + 1).toString().padStart(2, '0'); + const dd = date.getUTCDate().toString().padStart(2, '0'); + const hh = date.getUTCHours().toString().padStart(2, '0'); + const mi = date.getUTCMinutes().toString().padStart(2, '0'); + const ss = date.getUTCSeconds().toString().padStart(2, '0'); + return { value: `${yyyy}${mm}${dd}T${hh}${mi}${ss}Z`, params: [] }; + } + + return { value, params: [] }; +} + +async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 30000): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +async function queryEvents(config: z.infer, from: string, to: string): Promise { + const calendarUrl = normalizeCalendarUrl(config.calendarUrl); + const headers: Record = { + 'Content-Type': 'application/xml; charset=utf-8', + Depth: '1', + ...buildAuthHeaders(config), + ...(config.headers ?? {}), + }; + + const reportBody = `\n\n \n \n \n \n \n \n \n \n \n \n \n`; + + const response = await fetchWithTimeout(calendarUrl, { + method: 'REPORT', + headers, + body: reportBody, + }, 30000); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`CalDAV REPORT failed: ${response.status} ${error.slice(0, 200)}`); + } + + const xml = await response.text(); + const responses = parseMultiStatus(xml); + const events: CalendarEvent[] = []; + + for (const resp of responses) { + if (!resp.calendarData) continue; + const parsed = parseIcs(resp.calendarData); + for (const event of parsed) { + events.push({ ...event, href: resp.href, etag: resp.etag }); + } + } + + return events; +} + +function buildEventUrl(calendarUrl: string, uid: string): string { + const base = normalizeCalendarUrl(calendarUrl); + return `${base}${encodeURIComponent(uid)}.ics`; +} + +function buildEventIcs(input: z.infer, uid: string): string { + const start = formatIcsDate(input.start, { allDay: input.allDay, timezone: input.timezone }); + let endValue = input.end ?? input.start; + if (input.allDay && !input.end) { + const startDate = new Date(input.start); + if (!Number.isNaN(startDate.getTime())) { + endValue = new Date(startDate.getTime() + 24 * 60 * 60 * 1000).toISOString(); + } + } + const end = formatIcsDate(endValue, { allDay: input.allDay, timezone: input.timezone }); + const dtstamp = formatIcsDate(new Date().toISOString(), { allDay: false, timezone: 'UTC' }); + + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Weavr//EN', + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP${dtstamp.params.length ? `;${dtstamp.params.join(';')}` : ''}:${dtstamp.value}`, + `DTSTART${start.params.length ? `;${start.params.join(';')}` : ''}:${start.value}`, + `DTEND${end.params.length ? `;${end.params.join(';')}` : ''}:${end.value}`, + `SUMMARY:${escapeIcsText(input.summary)}`, + ]; + + if (input.description) { + lines.push(`DESCRIPTION:${escapeIcsText(input.description)}`); + } + if (input.location) { + lines.push(`LOCATION:${escapeIcsText(input.location)}`); + } + if (input.organizer) { + lines.push(`ORGANIZER:mailto:${input.organizer}`); + } + if (input.attendees) { + for (const attendee of input.attendees) { + const params = [ + attendee.name ? `CN=${escapeIcsText(attendee.name)}` : undefined, + attendee.rsvp ? 'RSVP=TRUE' : undefined, + ].filter(Boolean); + lines.push(`ATTENDEE${params.length ? `;${params.join(';')}` : ''}:mailto:${attendee.email}`); + } + } + + lines.push('END:VEVENT', 'END:VCALENDAR'); + return lines.join('\r\n'); +} + +// Track active polling triggers for cleanup +const activePollers = new Map void }>(); + +export default definePlugin({ + name: 'calendar', + version: '1.0.0', + description: 'CalDAV calendar actions and polling triggers', + + actions: [ + defineAction({ + name: 'list_events', + description: 'List events from a CalDAV calendar', + schema: ListEventsSchema, + async execute(ctx) { + const config = ListEventsSchema.parse(ctx.config); + const now = new Date(); + const from = config.from ? formatIcsDate(config.from, { allDay: false, timezone: 'UTC' }).value : formatIcsDate(now.toISOString(), { allDay: false, timezone: 'UTC' }).value; + const to = config.to ? formatIcsDate(config.to, { allDay: false, timezone: 'UTC' }).value : formatIcsDate(new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), { allDay: false, timezone: 'UTC' }).value; + + const events = await queryEvents(config, from, to); + const limited = config.limit ? events.slice(0, config.limit) : events; + + return { + count: limited.length, + range: { from, to }, + events: limited, + }; + }, + }), + + defineAction({ + name: 'create_event', + description: 'Create or update an event in a CalDAV calendar', + schema: CreateEventSchema, + async execute(ctx) { + const config = CreateEventSchema.parse(ctx.config); + const uid = config.uid ?? randomUUID(); + const url = buildEventUrl(config.calendarUrl, uid); + + const headers: Record = { + 'Content-Type': 'text/calendar; charset=utf-8', + ...buildAuthHeaders(config), + ...(config.headers ?? {}), + }; + + if (config.ifMatch) { + headers['If-Match'] = config.ifMatch; + } + + const body = buildEventIcs(config, uid); + const response = await fetchWithTimeout(url, { + method: 'PUT', + headers, + body, + }, 30000); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`CalDAV PUT failed: ${response.status} ${error.slice(0, 200)}`); + } + + return { + success: true, + uid, + url, + status: response.status, + }; + }, + }), + + defineAction({ + name: 'delete_event', + description: 'Delete an event from a CalDAV calendar', + schema: DeleteEventSchema, + async execute(ctx) { + const config = DeleteEventSchema.parse(ctx.config); + const url = buildEventUrl(config.calendarUrl, config.uid); + + const headers: Record = { + ...buildAuthHeaders(config), + ...(config.headers ?? {}), + }; + + if (config.ifMatch) { + headers['If-Match'] = config.ifMatch; + } + + const response = await fetchWithTimeout(url, { + method: 'DELETE', + headers, + }, 30000); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`CalDAV DELETE failed: ${response.status} ${error.slice(0, 200)}`); + } + + return { + success: true, + uid: config.uid, + status: response.status, + }; + }, + }), + ], + + triggers: [ + defineTrigger({ + name: 'event_upcoming', + description: 'Poll CalDAV calendar for upcoming events', + schema: UpcomingTriggerSchema, + async setup(config, emit) { + const parsed = UpcomingTriggerSchema.parse(config); + const pollKey = `calendar-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const seen = new Set(); + let polling = false; + + const poll = async (): Promise => { + if (polling) return; + polling = true; + try { + const now = Date.now(); + const fromIso = new Date(now - parsed.lookbackMinutes * 60 * 1000).toISOString(); + const toIso = new Date(now + parsed.windowMinutes * 60 * 1000).toISOString(); + const from = formatIcsDate(fromIso, { allDay: false, timezone: 'UTC' }).value; + const to = formatIcsDate(toIso, { allDay: false, timezone: 'UTC' }).value; + + const events = await queryEvents(parsed, from, to); + for (const event of events) { + const key = `${event.uid ?? 'unknown'}:${event.start ?? 'unknown'}`; + if (seen.has(key)) continue; + seen.add(key); + + emit({ + type: 'calendar.event_upcoming', + calendarUrl: parsed.calendarUrl, + event, + windowMinutes: parsed.windowMinutes, + fetchedAt: new Date().toISOString(), + }); + } + } catch (err) { + console.error('[calendar] Polling error:', err); + } finally { + polling = false; + } + }; + + const interval = setInterval(poll, parsed.pollIntervalSeconds * 1000); + void poll(); + + activePollers.set(pollKey, { stop: () => clearInterval(interval) }); + console.log(`[calendar] Polling for upcoming events every ${parsed.pollIntervalSeconds}s`); + + return () => { + clearInterval(interval); + activePollers.delete(pollKey); + console.log('[calendar] Polling stopped'); + }; + }, + }), + ], + + hooks: { + async onUnload() { + for (const poller of activePollers.values()) { + poller.stop(); + } + activePollers.clear(); + console.log('[calendar] Cleaned up active pollers'); + }, + }, +}); diff --git a/src/plugins/builtin/email/index.ts b/src/plugins/builtin/email/index.ts index d6214f6..942e555 100644 --- a/src/plugins/builtin/email/index.ts +++ b/src/plugins/builtin/email/index.ts @@ -1,5 +1,12 @@ -import { definePlugin, defineAction } from '../../sdk/types.js'; +import { definePlugin, defineAction, defineTrigger } from '../../sdk/types.js'; import { z } from 'zod'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { parse as parseYaml } from 'yaml'; +import { connect as netConnect } from 'node:net'; +import { connect as tlsConnect } from 'node:tls'; +import { randomUUID } from 'node:crypto'; const SendEmailSchema = z.object({ to: z.union([z.string().email(), z.array(z.string().email())]), @@ -10,10 +17,33 @@ const SendEmailSchema = z.object({ replyTo: z.string().email().optional(), cc: z.union([z.string().email(), z.array(z.string().email())]).optional(), bcc: z.union([z.string().email(), z.array(z.string().email())]).optional(), + provider: z.enum(['auto', 'api', 'smtp']).optional(), + smtp: z.object({ + host: z.string(), + port: z.number().optional(), + secure: z.boolean().optional(), + user: z.string().optional(), + pass: z.string().optional(), + authMethod: z.enum(['login', 'plain']).optional(), + }).optional(), }); +const InboundConfigSchema = z.object({ + path: z.string().default('email'), + provider: z.string().optional(), +}); + +interface SmtpConfig { + host: string; + port: number; + secure: boolean; + user?: string; + pass?: string; + authMethod: 'login' | 'plain'; +} + // Simple email sending using fetch to an email API service -// For production, you'd want to use nodemailer or similar +// For production, you'd want to use a provider SDK or a robust SMTP client async function sendViaAPI(config: { apiKey: string; to: string[]; @@ -21,7 +51,10 @@ async function sendViaAPI(config: { subject: string; text?: string; html?: string; -}): Promise<{ success: boolean; messageId?: string }> { + replyTo?: string; + cc?: string[]; + bcc?: string[]; +}): Promise<{ success: boolean; messageId?: string; provider: string }> { // This uses a generic email API pattern - you can swap for Resend, SendGrid, etc. const response = await fetch('https://api.resend.com/emails', { method: 'POST', @@ -32,6 +65,9 @@ async function sendViaAPI(config: { body: JSON.stringify({ from: config.from, to: config.to, + cc: config.cc, + bcc: config.bcc, + reply_to: config.replyTo, subject: config.subject, text: config.text, html: config.html, @@ -44,13 +80,284 @@ async function sendViaAPI(config: { } const data = await response.json() as { id?: string }; - return { success: true, messageId: data.id }; + return { success: true, messageId: data.id, provider: 'api' }; +} + +function getGlobalSmtpConfig(): SmtpConfig | undefined { + try { + const configPath = join(homedir(), '.weavr', 'config.yaml'); + if (!existsSync(configPath)) return undefined; + const content = readFileSync(configPath, 'utf-8'); + const config = parseYaml(content) as { email?: { smtp?: Record } }; + const smtp = config?.email?.smtp; + if (!smtp || typeof smtp !== 'object') return undefined; + + const host = smtp.host as string | undefined; + if (!host) return undefined; + + return { + host, + port: (smtp.port as number) ?? 587, + secure: (smtp.secure as boolean) ?? false, + user: smtp.user as string | undefined, + pass: smtp.pass as string | undefined, + authMethod: (smtp.authMethod as 'login' | 'plain') ?? 'login', + }; + } catch { + return undefined; + } +} + +function getEnvSmtpConfig(env: Record): SmtpConfig | undefined { + const host = env.SMTP_HOST ?? process.env.SMTP_HOST; + if (!host) return undefined; + const portValue = env.SMTP_PORT ?? process.env.SMTP_PORT; + const secureValue = env.SMTP_SECURE ?? process.env.SMTP_SECURE; + const parsedPort = portValue ? Number(portValue) : 587; + const port = Number.isNaN(parsedPort) ? 587 : parsedPort; + + return { + host, + port, + secure: secureValue === 'true' || secureValue === '1', + user: env.SMTP_USER ?? process.env.SMTP_USER, + pass: env.SMTP_PASS ?? process.env.SMTP_PASS, + authMethod: ((env.SMTP_AUTH_METHOD ?? process.env.SMTP_AUTH_METHOD) as 'login' | 'plain') ?? 'login', + }; +} + +function resolveSmtpConfig(ctx: { config: Record; env: Record }): SmtpConfig | undefined { + const inline = ctx.config.smtp as Record | undefined; + if (inline?.host) { + const inlinePort = inline.port ? Number(inline.port) : 587; + return { + host: String(inline.host), + port: Number.isNaN(inlinePort) ? 587 : inlinePort, + secure: inline.secure === true, + user: inline.user ? String(inline.user) : undefined, + pass: inline.pass ? String(inline.pass) : undefined, + authMethod: (inline.authMethod as 'login' | 'plain') ?? 'login', + }; + } + + return getEnvSmtpConfig(ctx.env) ?? getGlobalSmtpConfig(); +} + +function buildMimeMessage(config: { + from: string; + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + text?: string; + html?: string; + replyTo?: string; +}): { message: string; messageId: string } { + const messageId = `${randomUUID()}@weavr.local`; + const headers: string[] = [ + `From: ${config.from}`, + `To: ${config.to.join(', ')}`, + config.cc.length > 0 ? `Cc: ${config.cc.join(', ')}` : '', + `Subject: ${config.subject}`, + `Date: ${new Date().toUTCString()}`, + `Message-ID: <${messageId}>`, + config.replyTo ? `Reply-To: ${config.replyTo}` : '', + 'MIME-Version: 1.0', + ].filter(Boolean); + + const text = config.text ?? ''; + const html = config.html; + + if (html && text) { + const boundary = `weavr_${randomUUID()}`; + headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); + + const body = [ + `--${boundary}`, + 'Content-Type: text/plain; charset="utf-8"', + '', + text, + `--${boundary}`, + 'Content-Type: text/html; charset="utf-8"', + '', + html, + `--${boundary}--`, + ].join('\r\n'); + + return { message: headers.join('\r\n') + '\r\n\r\n' + body, messageId }; + } + + const contentType = html ? 'text/html' : 'text/plain'; + headers.push(`Content-Type: ${contentType}; charset="utf-8"`); + + return { + message: headers.join('\r\n') + '\r\n\r\n' + (html ?? text), + messageId, + }; +} + +async function readSmtpResponse(socket: ReturnType, timeoutMs = 30000): Promise<{ code: number; message: string }> { + return await new Promise((resolve, reject) => { + let buffer = ''; + const lines: string[] = []; + let resolved = false; + + const timeout = setTimeout(() => { + if (resolved) return; + cleanup(); + reject(new Error('SMTP timeout waiting for response')); + }, timeoutMs); + + function cleanup(): void { + clearTimeout(timeout); + socket.off('data', onData); + socket.off('error', onError); + } + + function onError(err: Error): void { + if (resolved) return; + cleanup(); + reject(err); + } + + function onData(chunk: Buffer): void { + buffer += chunk.toString('utf-8'); + while (buffer.includes('\n')) { + const idx = buffer.indexOf('\n'); + const line = buffer.slice(0, idx).replace(/\r$/, ''); + buffer = buffer.slice(idx + 1); + lines.push(line); + + const match = line.match(/^(\d{3})([ -])/); + if (match) { + const code = Number(match[1]); + const hasMore = match[2] === '-'; + if (!hasMore) { + resolved = true; + cleanup(); + resolve({ code, message: lines.join('\n') }); + return; + } + } + } + } + + socket.on('data', onData); + socket.on('error', onError); + }); +} + +async function sendSmtpCommand(socket: ReturnType, command: string): Promise<{ code: number; message: string }> { + socket.write(`${command}\r\n`); + return await readSmtpResponse(socket); +} + +async function sendViaSMTP(config: { + smtp: SmtpConfig; + from: string; + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + text?: string; + html?: string; + replyTo?: string; +}): Promise<{ success: boolean; messageId: string; provider: string }> { + const { smtp } = config; + const socket = smtp.secure + ? tlsConnect({ host: smtp.host, port: smtp.port }) + : netConnect({ host: smtp.host, port: smtp.port }); + + await new Promise((resolve, reject) => { + socket.once('connect', () => resolve()); + socket.once('error', (err) => reject(err)); + }); + + const greeting = await readSmtpResponse(socket); + if (greeting.code >= 400) { + socket.end(); + throw new Error(`SMTP error: ${greeting.message}`); + } + + let response = await sendSmtpCommand(socket, `EHLO weavr.local`); + if (response.code >= 400) { + response = await sendSmtpCommand(socket, `HELO weavr.local`); + } + + if (smtp.user && smtp.pass) { + if (smtp.authMethod === 'plain') { + const authString = Buffer.from(`\u0000${smtp.user}\u0000${smtp.pass}`, 'utf-8').toString('base64'); + response = await sendSmtpCommand(socket, `AUTH PLAIN ${authString}`); + } else { + response = await sendSmtpCommand(socket, 'AUTH LOGIN'); + if (response.code === 334) { + response = await sendSmtpCommand(socket, Buffer.from(smtp.user, 'utf-8').toString('base64')); + } + if (response.code === 334) { + response = await sendSmtpCommand(socket, Buffer.from(smtp.pass, 'utf-8').toString('base64')); + } + } + + if (response.code >= 400) { + socket.end(); + throw new Error(`SMTP authentication failed: ${response.message}`); + } + } + + response = await sendSmtpCommand(socket, `MAIL FROM:<${config.from}>`); + if (response.code >= 400) { + socket.end(); + throw new Error(`SMTP MAIL FROM failed: ${response.message}`); + } + + const recipients = [...config.to, ...config.cc, ...config.bcc]; + for (const recipient of recipients) { + response = await sendSmtpCommand(socket, `RCPT TO:<${recipient}>`); + if (response.code >= 400) { + socket.end(); + throw new Error(`SMTP RCPT TO failed: ${response.message}`); + } + } + + response = await sendSmtpCommand(socket, 'DATA'); + if (response.code >= 400) { + socket.end(); + throw new Error(`SMTP DATA failed: ${response.message}`); + } + + const { message, messageId } = buildMimeMessage({ + from: config.from, + to: config.to, + cc: config.cc, + bcc: config.bcc, + subject: config.subject, + text: config.text, + html: config.html, + replyTo: config.replyTo, + }); + + const normalized = message + .replace(/\r?\n/g, '\r\n') + .replace(/^\./, '..') + .replace(/\r\n\./g, '\r\n..'); + + socket.write(normalized + '\r\n.\r\n'); + response = await readSmtpResponse(socket); + if (response.code >= 400) { + socket.end(); + throw new Error(`SMTP message rejected: ${response.message}`); + } + + await sendSmtpCommand(socket, 'QUIT'); + socket.end(); + + return { success: true, messageId, provider: 'smtp' }; } export default definePlugin({ name: 'email', - version: '1.0.0', - description: 'Send emails via SMTP or API', + version: '1.1.0', + description: 'Send emails via SMTP or API and receive inbound webhooks', actions: [ defineAction({ @@ -60,14 +367,24 @@ export default definePlugin({ async execute(ctx) { const emailConfig = SendEmailSchema.parse(ctx.config); const apiKey = (ctx.config.apiKey as string) ?? ctx.env.EMAIL_API_KEY ?? process.env.RESEND_API_KEY; + const provider = emailConfig.provider ?? 'auto'; const to = Array.isArray(emailConfig.to) ? emailConfig.to : [emailConfig.to]; - const from = emailConfig.from ?? ctx.env.EMAIL_FROM ?? 'noreply@example.com'; + const cc = emailConfig.cc ? (Array.isArray(emailConfig.cc) ? emailConfig.cc : [emailConfig.cc]) : []; + const bcc = emailConfig.bcc ? (Array.isArray(emailConfig.bcc) ? emailConfig.bcc : [emailConfig.bcc]) : []; + const smtpConfig = resolveSmtpConfig(ctx); + const from = emailConfig.from + ?? ctx.env.EMAIL_FROM + ?? process.env.EMAIL_FROM + ?? smtpConfig?.user + ?? 'noreply@example.com'; ctx.log(`Sending email to ${to.join(', ')}: ${emailConfig.subject}`); - if (apiKey) { - // Use API-based sending + if (provider === 'api' || (provider === 'auto' && apiKey)) { + if (!apiKey) { + throw new Error('Email API key missing. Set EMAIL_API_KEY or RESEND_API_KEY, or use SMTP.'); + } return await sendViaAPI({ apiKey, to, @@ -75,17 +392,32 @@ export default definePlugin({ subject: emailConfig.subject, text: emailConfig.text, html: emailConfig.html, + replyTo: emailConfig.replyTo, + cc, + bcc, }); - } else { - // Fallback to console log for development - console.log(`[email] Would send email: - To: ${to.join(', ')} - From: ${from} - Subject: ${emailConfig.subject} - Body: ${emailConfig.text ?? emailConfig.html ?? '(empty)'} - `); - return { success: true, messageId: 'dev-mode' }; } + + if (provider === 'smtp' || (provider === 'auto' && smtpConfig)) { + if (!smtpConfig) { + throw new Error('SMTP configuration missing. Provide smtp config or SMTP_* environment variables.'); + } + return await sendViaSMTP({ + smtp: smtpConfig, + from, + to, + cc, + bcc, + subject: emailConfig.subject, + text: emailConfig.text, + html: emailConfig.html, + replyTo: emailConfig.replyTo, + }); + } + + // Fallback to console log for development + console.log(`[email] Would send email:\n To: ${to.join(', ')}\n From: ${from}\n Subject: ${emailConfig.subject}\n Body: ${emailConfig.text ?? emailConfig.html ?? '(empty)'}\n`); + return { success: true, messageId: 'dev-mode', provider: 'dry-run' }; }, }), @@ -125,7 +457,7 @@ export default definePlugin({ } const data = await response.json() as { id?: string }; - return { success: true, messageId: data.id }; + return { success: true, messageId: data.id, provider: 'api' }; } return { @@ -140,6 +472,14 @@ export default definePlugin({ }), ], + triggers: [ + defineTrigger({ + name: 'inbound', + description: 'Trigger on inbound email (via webhook payload)', + schema: InboundConfigSchema, + }), + ], + auth: { type: 'api_key', config: { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 7de7c4e..272221a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -10,6 +10,7 @@ import discordPlugin from './builtin/discord/index.js'; import linearPlugin from './builtin/linear/index.js'; import notionPlugin from './builtin/notion/index.js'; import emailPlugin from './builtin/email/index.js'; +import calendarPlugin from './builtin/calendar/index.js'; import aiPlugin from './builtin/ai/index.js'; import jsonPlugin from './builtin/json/index.js'; @@ -33,6 +34,7 @@ const builtinPlugins = [ linearPlugin, notionPlugin, emailPlugin, + calendarPlugin, aiPlugin, jsonPlugin, // Local/system plugins diff --git a/src/types/index.ts b/src/types/index.ts index 0dd2175..faf4b20 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -244,6 +244,24 @@ export interface WeavrConfig { }; // GitHub webhook secret for signature verification githubWebhookSecret?: string; + email?: { + smtp?: { + host: string; + port?: number; + secure?: boolean; + user?: string; + pass?: string; + authMethod?: 'login' | 'plain'; + }; + }; + calendar?: { + caldav?: { + calendarUrl: string; + username?: string; + password?: string; + bearerToken?: string; + }; + }; } export const DEFAULT_CONFIG: WeavrConfig = { From 68d0e9a68940945eb2c6eddc3266432f22ac7942 Mon Sep 17 00:00:00 2001 From: Manthan Date: Fri, 6 Feb 2026 23:15:55 -0800 Subject: [PATCH 2/2] Add email/calendar settings and workflow builder support --- src/gateway/server.ts | 60 ++++ src/web/app/components/IntegrationIcon.tsx | 3 + src/web/app/components/WorkflowBuilder.tsx | 131 ++++++++- src/web/app/pages/Settings.tsx | 321 ++++++++++++++++++++- 4 files changed, 511 insertions(+), 4 deletions(-) diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8eb5359..a4bc089 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -771,6 +771,22 @@ export function createGatewayServer(config: WeavrConfig): GatewayServer { apiKey: config.webSearch.apiKey ? '••••••••' : undefined, hasApiKey: Boolean(config.webSearch.apiKey), } : undefined, + email: config.email ? { + smtp: config.email.smtp ? { + ...config.email.smtp, + pass: config.email.smtp.pass ? '••••••••' : undefined, + hasPass: Boolean(config.email.smtp.pass), + } : undefined, + } : undefined, + calendar: config.calendar ? { + caldav: config.calendar.caldav ? { + ...config.calendar.caldav, + password: config.calendar.caldav.password ? '••••••••' : undefined, + bearerToken: config.calendar.caldav.bearerToken ? '••••••••' : undefined, + hasPassword: Boolean(config.calendar.caldav.password), + hasBearerToken: Boolean(config.calendar.caldav.bearerToken), + } : undefined, + } : undefined, // Messaging: hide tokens but indicate if configured messaging: config.messaging ? { telegram: config.messaging.telegram ? { @@ -893,6 +909,50 @@ export function createGatewayServer(config: WeavrConfig): GatewayServer { } } + // Handle email config - preserve SMTP password if masked value is sent + if (newConfig.email) { + mergedConfig.email = { + ...existingConfig.email, + ...newConfig.email, + }; + if (newConfig.email.smtp) { + mergedConfig.email.smtp = { + ...existingConfig.email?.smtp, + ...newConfig.email.smtp, + }; + if (newConfig.email.smtp.pass && newConfig.email.smtp.pass !== '••••••••') { + mergedConfig.email.smtp.pass = newConfig.email.smtp.pass; + } else if (existingConfig.email?.smtp?.pass) { + mergedConfig.email.smtp.pass = existingConfig.email.smtp.pass; + } + } + } + + // Handle calendar config - preserve secrets if masked values are sent + if (newConfig.calendar) { + mergedConfig.calendar = { + ...existingConfig.calendar, + ...newConfig.calendar, + }; + if (newConfig.calendar.caldav) { + mergedConfig.calendar.caldav = { + ...existingConfig.calendar?.caldav, + ...newConfig.calendar.caldav, + }; + if (newConfig.calendar.caldav.password && newConfig.calendar.caldav.password !== '••••••••') { + mergedConfig.calendar.caldav.password = newConfig.calendar.caldav.password; + } else if (existingConfig.calendar?.caldav?.password) { + mergedConfig.calendar.caldav.password = existingConfig.calendar.caldav.password; + } + + if (newConfig.calendar.caldav.bearerToken && newConfig.calendar.caldav.bearerToken !== '••••••••') { + mergedConfig.calendar.caldav.bearerToken = newConfig.calendar.caldav.bearerToken; + } else if (existingConfig.calendar?.caldav?.bearerToken) { + mergedConfig.calendar.caldav.bearerToken = existingConfig.calendar.caldav.bearerToken; + } + } + } + // Ensure directories exist await mkdir(weavrDir, { recursive: true }); await mkdir(join(weavrDir, 'workflows'), { recursive: true }); diff --git a/src/web/app/components/IntegrationIcon.tsx b/src/web/app/components/IntegrationIcon.tsx index cfe68b0..7245e91 100644 --- a/src/web/app/components/IntegrationIcon.tsx +++ b/src/web/app/components/IntegrationIcon.tsx @@ -12,6 +12,7 @@ import { FaRobot, FaEnvelope, FaClock, + FaCalendarDays, FaGlobe, FaCode, FaApple, @@ -66,6 +67,7 @@ const iconMap: Record = { http: '#60a5fa', cron: '#fbbf24', email: '#f472b6', + calendar: '#38bdf8', json: '#34d399', imessage: '#60a5fa', file: '#94a3b8', diff --git a/src/web/app/components/WorkflowBuilder.tsx b/src/web/app/components/WorkflowBuilder.tsx index 078ace5..1513661 100644 --- a/src/web/app/components/WorkflowBuilder.tsx +++ b/src/web/app/components/WorkflowBuilder.tsx @@ -284,9 +284,10 @@ const ACTION_SCHEMAS: ActionSchema[] = [ { name: 'tools', label: 'Available Tools', type: 'multiselect', options: [ { value: 'web_search', label: 'Web Search' }, { value: 'web_fetch', label: 'Web Fetch' }, + { value: 'http_request', label: 'HTTP Request' }, { value: 'shell', label: 'Shell Commands' }, { value: 'filesystem', label: 'File System' }, - ], default: ['web_search', 'web_fetch'] }, + ], default: ['web_search', 'web_fetch', 'http_request', 'filesystem'] }, { name: 'memory', label: 'Memory Blocks', type: 'multiselect', options: [] }, { name: 'maxIterations', label: 'Max Iterations', type: 'number', placeholder: '10', default: 10 }, ], @@ -300,13 +301,87 @@ const ACTION_SCHEMAS: ActionSchema[] = [ { id: 'email.send', label: 'Send Email', - description: 'Send an email via SMTP', + description: 'Send an email via SMTP or API', icon: 'email', category: 'Email', fields: [ { name: 'to', label: 'To', type: 'text', placeholder: 'recipient@example.com', required: true }, + { name: 'cc', label: 'CC', type: 'text', placeholder: 'cc@example.com' }, + { name: 'bcc', label: 'BCC', type: 'text', placeholder: 'bcc@example.com' }, + { name: 'from', label: 'From', type: 'text', placeholder: 'noreply@example.com' }, + { name: 'replyTo', label: 'Reply-To', type: 'text', placeholder: 'reply@example.com' }, { name: 'subject', label: 'Subject', type: 'text', placeholder: 'Email subject', required: true }, - { name: 'body', label: 'Body', type: 'textarea', placeholder: 'Email content...', required: true }, + { name: 'text', label: 'Text Body', type: 'textarea', placeholder: 'Plain text content...' }, + { name: 'html', label: 'HTML Body', type: 'textarea', placeholder: '

Hello...

' }, + { name: 'provider', label: 'Provider', type: 'select', options: [ + { value: 'auto', label: 'Auto' }, + { value: 'smtp', label: 'SMTP' }, + { value: 'api', label: 'API' }, + ], default: 'auto' }, + ], + }, + // Calendar (CalDAV) + { + id: 'calendar.list_events', + label: 'List Events', + description: 'List events from a CalDAV calendar', + icon: 'calendar', + category: 'Calendar', + fields: [ + { name: 'calendarUrl', label: 'Calendar URL', type: 'text', placeholder: 'https://cal.example.com/dav/calendars/user/default/', required: true }, + { name: 'username', label: 'Username', type: 'text', placeholder: 'user@example.com' }, + { name: 'password', label: 'Password', type: 'text', placeholder: 'App password' }, + { name: 'bearerToken', label: 'Bearer Token', type: 'text', placeholder: 'Bearer token (optional)' }, + { name: 'from', label: 'From', type: 'text', placeholder: '2026-02-07T00:00:00Z' }, + { name: 'to', label: 'To', type: 'text', placeholder: '2026-02-14T00:00:00Z' }, + { name: 'limit', label: 'Limit', type: 'number', placeholder: '10', default: 10 }, + ], + outputFields: [ + { name: 'count', type: 'number', description: 'Number of events returned' }, + { name: 'events', type: 'array', description: 'List of events' }, + ], + }, + { + id: 'calendar.create_event', + label: 'Create Event', + description: 'Create or update a CalDAV event', + icon: 'calendar', + category: 'Calendar', + fields: [ + { name: 'calendarUrl', label: 'Calendar URL', type: 'text', placeholder: 'https://cal.example.com/dav/calendars/user/default/', required: true }, + { name: 'username', label: 'Username', type: 'text', placeholder: 'user@example.com' }, + { name: 'password', label: 'Password', type: 'text', placeholder: 'App password' }, + { name: 'bearerToken', label: 'Bearer Token', type: 'text', placeholder: 'Bearer token (optional)' }, + { name: 'summary', label: 'Summary', type: 'text', placeholder: 'Weekly sync', required: true }, + { name: 'description', label: 'Description', type: 'textarea', placeholder: 'Agenda...' }, + { name: 'location', label: 'Location', type: 'text', placeholder: 'Conference room' }, + { name: 'start', label: 'Start', type: 'text', placeholder: '2026-02-10T15:00:00Z', required: true }, + { name: 'end', label: 'End', type: 'text', placeholder: '2026-02-10T15:30:00Z' }, + { name: 'allDay', label: 'All Day', type: 'boolean' }, + { name: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/New_York' }, + ], + outputFields: [ + { name: 'uid', type: 'string', description: 'Event UID' }, + { name: 'url', type: 'string', description: 'Event URL' }, + { name: 'status', type: 'number', description: 'HTTP status' }, + ], + }, + { + id: 'calendar.delete_event', + label: 'Delete Event', + description: 'Delete a CalDAV event', + icon: 'calendar', + category: 'Calendar', + fields: [ + { name: 'calendarUrl', label: 'Calendar URL', type: 'text', placeholder: 'https://cal.example.com/dav/calendars/user/default/', required: true }, + { name: 'username', label: 'Username', type: 'text', placeholder: 'user@example.com' }, + { name: 'password', label: 'Password', type: 'text', placeholder: 'App password' }, + { name: 'bearerToken', label: 'Bearer Token', type: 'text', placeholder: 'Bearer token (optional)' }, + { name: 'uid', label: 'Event UID', type: 'text', placeholder: 'event-uid', required: true }, + ], + outputFields: [ + { name: 'uid', type: 'string', description: 'Event UID' }, + { name: 'status', type: 'number', description: 'HTTP status' }, ], }, // JSON @@ -576,6 +651,17 @@ const TRIGGER_SCHEMAS: ActionSchema[] = [ ], default: 'POST' }, ], }, + { + id: 'email.inbound', + label: 'Inbound Email', + description: 'Trigger on inbound email webhooks', + icon: 'email', + category: 'Email', + fields: [ + { name: 'path', label: 'Webhook Path', type: 'text', placeholder: 'email', required: true, default: 'email' }, + { name: 'provider', label: 'Provider', type: 'text', placeholder: 'resend (optional)' }, + ], + }, { id: 'cron.schedule', label: 'Schedule', @@ -587,6 +673,22 @@ const TRIGGER_SCHEMAS: ActionSchema[] = [ { name: 'timezone', label: 'Timezone', type: 'select', options: TIMEZONE_OPTIONS, default: '' }, ], }, + { + id: 'calendar.event_upcoming', + label: 'Calendar Event', + description: 'Trigger when a CalDAV event is upcoming', + icon: 'calendar', + category: 'Calendar', + fields: [ + { name: 'calendarUrl', label: 'Calendar URL', type: 'text', placeholder: 'https://cal.example.com/dav/calendars/user/default/', required: true }, + { name: 'username', label: 'Username', type: 'text', placeholder: 'user@example.com' }, + { name: 'password', label: 'Password', type: 'text', placeholder: 'App password' }, + { name: 'bearerToken', label: 'Bearer Token', type: 'text', placeholder: 'Bearer token (optional)' }, + { name: 'windowMinutes', label: 'Window Minutes', type: 'number', placeholder: '60', default: 60 }, + { name: 'pollIntervalSeconds', label: 'Poll Interval (seconds)', type: 'number', placeholder: '60', default: 60 }, + { name: 'lookbackMinutes', label: 'Lookback Minutes', type: 'number', placeholder: '5', default: 5 }, + ], + }, { id: 'github.push', label: 'GitHub Push', @@ -843,6 +945,29 @@ function getTriggerVariables(triggerType?: string): Array<{ path: string; descri ]; } + if (triggerType?.startsWith('email.inbound')) { + return [ + ...common, + { path: 'trigger.path', description: 'Webhook path' }, + { path: 'trigger.provider', description: 'Provider identifier' }, + { path: 'trigger.data.body', description: 'Inbound payload body' }, + { path: 'trigger.data.headers', description: 'Inbound payload headers' }, + ]; + } + + if (triggerType?.startsWith('calendar.event_upcoming')) { + return [ + ...common, + { path: 'trigger.calendarUrl', description: 'Calendar URL' }, + { path: 'trigger.event.summary', description: 'Event summary' }, + { path: 'trigger.event.start', description: 'Event start time' }, + { path: 'trigger.event.end', description: 'Event end time' }, + { path: 'trigger.event.location', description: 'Event location' }, + { path: 'trigger.windowMinutes', description: 'Window minutes' }, + { path: 'trigger.fetchedAt', description: 'Fetch timestamp' }, + ]; + } + // Generic fallback return [ ...common, diff --git a/src/web/app/pages/Settings.tsx b/src/web/app/pages/Settings.tsx index b59ef03..0bdc7d0 100644 --- a/src/web/app/pages/Settings.tsx +++ b/src/web/app/pages/Settings.tsx @@ -55,6 +55,27 @@ interface Config { host: string; }; timezone?: string; + email?: { + smtp?: { + host?: string; + port?: number; + secure?: boolean; + user?: string; + pass?: string; + authMethod?: 'login' | 'plain'; + hasPass?: boolean; + }; + }; + calendar?: { + caldav?: { + calendarUrl?: string; + username?: string; + password?: string; + bearerToken?: string; + hasPassword?: boolean; + hasBearerToken?: boolean; + }; + }; ai?: { provider?: string; model?: string; @@ -122,6 +143,24 @@ export function Settings() { const [whatsappConnecting, setWhatsappConnecting] = useState(false); const [whatsappStatus, setWhatsappStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected'); + // Email SMTP state + const [smtpHost, setSmtpHost] = useState(''); + const [smtpPort, setSmtpPort] = useState(''); + const [smtpSecure, setSmtpSecure] = useState(false); + const [smtpUser, setSmtpUser] = useState(''); + const [smtpPass, setSmtpPass] = useState(''); + const [showSmtpPass, setShowSmtpPass] = useState(false); + const [smtpAuthMethod, setSmtpAuthMethod] = useState<'login' | 'plain'>('login'); + + // Calendar (CalDAV) state + const [caldavUrl, setCaldavUrl] = useState(''); + const [caldavUsername, setCaldavUsername] = useState(''); + const [caldavPassword, setCaldavPassword] = useState(''); + const [showCaldavPassword, setShowCaldavPassword] = useState(false); + const [caldavBearer, setCaldavBearer] = useState(''); + const [showCaldavBearer, setShowCaldavBearer] = useState(false); + const [caldavAuthMode, setCaldavAuthMode] = useState<'basic' | 'bearer'>('basic'); + // Timezone - detect system default const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -143,11 +182,31 @@ export function Settings() { ]}, ]; + const applyEmailCalendarConfig = (nextConfig: Config | null) => { + if (!nextConfig) return; + + const smtp = nextConfig.email?.smtp; + setSmtpHost(smtp?.host ?? ''); + setSmtpPort(smtp?.port ? String(smtp.port) : ''); + setSmtpSecure(Boolean(smtp?.secure)); + setSmtpUser(smtp?.user ?? ''); + setSmtpAuthMethod(smtp?.authMethod ?? 'login'); + setSmtpPass(''); + + const caldav = nextConfig.calendar?.caldav; + setCaldavUrl(caldav?.calendarUrl ?? ''); + setCaldavUsername(caldav?.username ?? ''); + setCaldavAuthMode(caldav?.bearerToken ? 'bearer' : 'basic'); + setCaldavPassword(''); + setCaldavBearer(''); + }; + useEffect(() => { fetch('/api/config') .then((res) => res.json()) .then((data) => { setConfig(data.config); + applyEmailCalendarConfig(data.config); setLoading(false); }) .catch((err) => { @@ -271,6 +330,31 @@ export function Settings() { setMessage(null); try { + const smtpPortValue = smtpPort ? parseInt(smtpPort, 10) : undefined; + const smtpConfig = (smtpHost || smtpUser || smtpPass || smtpPort || config?.email?.smtp) ? { + host: smtpHost, + port: smtpPortValue, + secure: smtpSecure, + user: smtpUser || undefined, + pass: smtpPass || undefined, + authMethod: smtpAuthMethod, + } : undefined; + + const caldavConfig = (caldavUrl || caldavUsername || caldavPassword || caldavBearer || config?.calendar?.caldav) ? { + calendarUrl: caldavUrl, + ...(caldavAuthMode === 'basic' + ? { + username: caldavUsername || undefined, + password: caldavPassword || undefined, + bearerToken: undefined, + } + : { + username: undefined, + password: undefined, + bearerToken: caldavBearer || undefined, + }), + } : undefined; + const saveConfig = { ...config, ai: config.ai?.provider ? { @@ -281,6 +365,8 @@ export function Settings() { provider: 'brave', apiKey: braveApiKey, } : config.webSearch, + email: smtpConfig ? { smtp: smtpConfig } : config.email, + calendar: caldavConfig ? { caldav: caldavConfig } : config.calendar, messaging: { ...config.messaging, telegram: (telegramToken || telegramChatId) ? { @@ -303,10 +389,14 @@ export function Settings() { setBraveApiKey(''); // Clear the Brave API key field after saving setTelegramToken(''); // Clear the telegram token field after saving setTelegramChatId(''); // Clear the telegram chat ID field after saving + setSmtpPass(''); + setCaldavPassword(''); + setCaldavBearer(''); // Reload config to get updated hasApiKey status const reloadRes = await fetch('/api/config'); const reloadData = await reloadRes.json(); setConfig(reloadData.config); + applyEmailCalendarConfig(reloadData.config); } else { const data = await response.json(); setMessage({ type: 'error', text: data.error ?? 'Failed to save settings' }); @@ -494,6 +584,7 @@ export function Settings() { }; const isMacOS = navigator.platform.toLowerCase().includes('mac'); + const webhookBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''; return (
@@ -1113,6 +1204,233 @@ export function Settings() {
+
+

Email Configuration

+

+ Configure SMTP credentials for email.send actions. You can also use API keys in workflows. +

+ +
+
+ +
+
SMTP
+
+ Used when provider is smtp or auto +
+
+ {config?.email?.smtp?.host && ( + configured + )} +
+ +
+
+ + setSmtpHost(e.target.value)} + placeholder="smtp.example.com" + /> +
+
+ + setSmtpPort(e.target.value)} + placeholder="587" + /> +
+
+ +
+
+ + +
+
+ setSmtpSecure(e.target.checked)} + style={{ width: '14px', height: '14px' }} + /> + Use TLS (secure) +
+
+ +
+ + setSmtpUser(e.target.value)} + placeholder="your-email@example.com" + /> +
+ +
+ +
+ setSmtpPass(e.target.value)} + placeholder={config?.email?.smtp?.hasPass ? 'Password is set (enter new to change)' : 'SMTP password'} + style={{ flex: 1 }} + /> + +
+
+ +
+ Inbound email webhook: {webhookBaseUrl}/webhook/email +
+
+
+ +
+

Calendar Configuration

+

+ Configure CalDAV access for calendar.* actions and triggers. +

+ +
+
+ +
+
CalDAV
+
+ Nextcloud, iCloud, Fastmail, and more +
+
+ {config?.calendar?.caldav?.calendarUrl && ( + configured + )} +
+ +
+ + setCaldavUrl(e.target.value)} + placeholder="https://cal.example.com/dav/calendars/user/default/" + /> +
+ +
+ + +
+ + {caldavAuthMode === 'basic' ? ( + <> +
+ + setCaldavUsername(e.target.value)} + placeholder="user@example.com" + /> +
+ +
+ +
+ setCaldavPassword(e.target.value)} + placeholder={config?.calendar?.caldav?.hasPassword ? 'Password is set (enter new to change)' : 'CalDAV password'} + style={{ flex: 1 }} + /> + +
+
+ + ) : ( +
+ +
+ setCaldavBearer(e.target.value)} + placeholder={config?.calendar?.caldav?.hasBearerToken ? 'Token is set (enter new to change)' : 'Bearer token'} + style={{ flex: 1 }} + /> + +
+
+ )} +
+
+

Built-in Plugins

@@ -1125,7 +1443,8 @@ export function Settings() { { name: 'discord', version: '1.0.0', description: 'Discord webhooks' }, { name: 'linear', version: '1.0.0', description: 'Linear project management' }, { name: 'notion', version: '1.0.0', description: 'Notion pages & databases' }, - { name: 'email', version: '1.0.0', description: 'Email via SMTP' }, + { name: 'email', version: '1.1.0', description: 'Email via SMTP or API' }, + { name: 'calendar', version: '1.0.0', description: 'CalDAV calendar integration' }, { name: 'ai', version: '1.0.0', description: 'AI/LLM actions' }, { name: 'json', version: '1.0.0', description: 'JSON manipulation' }, ].map((plugin) => (