From 111dda91b851e2a20780790705bb9902d0c9f9de Mon Sep 17 00:00:00 2001 From: zap Date: Thu, 12 Mar 2026 20:58:02 +0000 Subject: [PATCH] feat(n8n-webhook): add gmail draft list/delete/send approval flows --- skills/n8n-webhook/SKILL.md | 19 ++- .../assets/openclaw-action.workflow.json | 4 +- .../assets/test-delete-email-draft.json | 7 ++ .../assets/test-list-email-drafts.json | 8 ++ .../assets/test-send-approved-email.json | 7 ++ .../assets/test-send-gmail-draft.json | 7 ++ .../n8n-webhook/references/openclaw-action.md | 90 +++++++++++++- skills/n8n-webhook/references/payloads.md | 69 +++++++++++ .../scripts/resolve-approval-with-gog.py | 115 ++++++++++++++++-- .../n8n-webhook/scripts/validate-workflow.py | 12 ++ 10 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 skills/n8n-webhook/assets/test-delete-email-draft.json create mode 100644 skills/n8n-webhook/assets/test-list-email-drafts.json create mode 100644 skills/n8n-webhook/assets/test-send-approved-email.json create mode 100644 skills/n8n-webhook/assets/test-send-gmail-draft.json diff --git a/skills/n8n-webhook/SKILL.md b/skills/n8n-webhook/SKILL.md index 3431589..32d1171 100644 --- a/skills/n8n-webhook/SKILL.md +++ b/skills/n8n-webhook/SKILL.md @@ -34,6 +34,12 @@ Keep the integration narrow: let OpenClaw decide what to do, and let n8n execute - `assets/test-append-log.json` - `assets/test-notify.json` - `assets/test-send-notification-draft.json` + - `assets/test-send-email-draft.json` + - `assets/test-list-email-drafts.json` + - `assets/test-delete-email-draft.json` + - `assets/test-send-gmail-draft.json` + - `assets/test-send-approved-email.json` + - `assets/test-create-calendar-event.json` ## Quick usage @@ -54,6 +60,7 @@ Call the preferred action-bus route: ```bash scripts/call-action.sh append_log --args '{"text":"backup complete"}' --request-id auto scripts/call-action.sh get_logs --args '{"limit":5}' --pretty +scripts/call-action.sh list_email_drafts --args '{"max":10}' --pretty ``` Call a test webhook while editing a flow: @@ -104,7 +111,10 @@ Use the included workflow asset when you want a ready-made local router for: - `get_logs` → read the most recent retained records from `actionLog` - `notify` → send through the current Telegram + Discord notification paths - `send_notification_draft` → queue approval-gated notifications that execute on approve through Telegram + Discord -- `send_email_draft` → queue approval-gated email drafts in workflow static data +- `send_email_draft` → queue approval-gated email draft creation proposals in workflow static data +- `list_email_drafts` → queue approval-gated Gmail draft list requests (read-only, low mutation level) +- `delete_email_draft` → queue approval-gated Gmail draft deletion requests +- `send_gmail_draft` (alias: `send_approved_email`) → queue approval-gated Gmail draft send requests - `create_calendar_event` → queue approval-gated calendar proposals in workflow static data - `approval_queue_add` / `approval_queue_list` / `approval_queue_resolve` → manage pending approvals and recent history - `approval_history_attach_execution` → let a host-side executor attach real execution metadata back onto approval history entries @@ -128,6 +138,13 @@ When email/calendar provider creds live on the host via `gog` rather than inside python3 scripts/resolve-approval-with-gog.py --id --decision approve ``` +Supported host-executed approval kinds: +- `email_draft` → `gog gmail drafts create` +- `email_list_drafts` → `gog gmail drafts list` +- `email_draft_delete` → `gog gmail drafts delete` +- `email_draft_send` → `gog gmail drafts send` +- `calendar_event` → `gog calendar create` + Practical note: - unattended execution needs `GOG_KEYRING_PASSWORD` available to the executor because `gog`'s file keyring cannot prompt in non-TTY automation - the included bridge auto-loads `/home/openclaw/.openclaw/credentials/gog.env` when present, so you can keep `GOG_ACCOUNT` and `GOG_KEYRING_PASSWORD` there with mode `600` diff --git a/skills/n8n-webhook/assets/openclaw-action.workflow.json b/skills/n8n-webhook/assets/openclaw-action.workflow.json index b4a385d..97bf534 100644 --- a/skills/n8n-webhook/assets/openclaw-action.workflow.json +++ b/skills/n8n-webhook/assets/openclaw-action.workflow.json @@ -30,7 +30,7 @@ "parameters": { "mode": "runOnceForEachItem", "language": "javaScript", - "jsCode": "return (async () => {\n const body = ($json.body && typeof $json.body === 'object') ? $json.body : $json;\n const action = typeof body.action === 'string' ? body.action : '';\n const args = (body.args && typeof body.args === 'object' && body.args !== null) ? body.args : {};\n const requestId = typeof body.request_id === 'string' ? body.request_id : '';\n const now = new Date().toISOString();\n const workflowStaticData = $getWorkflowStaticData('global');\n const maxLogEntries = 200;\n const maxQueueEntries = 200;\n const maxHistoryEntries = 200;\n const maxEventEntries = 200;\n const maxDedupEntries = 500;\n\n const clamp = (value, min, max, fallback) => {\n const num = Number(value);\n if (!Number.isFinite(num)) {\n return fallback;\n }\n return Math.max(min, Math.min(max, Math.trunc(num)));\n };\n\n const asString = (value, fallback = '') => (typeof value === 'string' ? value : fallback);\n\n const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);\n\n const trimText = (value) => asString(value).trim();\n\n const previewText = (value, max = 160) => {\n const text = trimText(value).replace(/\\s+/g, ' ');\n if (text.length <= max) {\n return text;\n }\n return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026`;\n };\n\n const ensureStringArray = (value) => {\n if (Array.isArray(value)) {\n return value.map((entry) => trimText(entry)).filter(Boolean);\n }\n if (typeof value === 'string') {\n const trimmed = trimText(value);\n return trimmed ? [trimmed] : [];\n }\n return [];\n };\n\n const pushRetained = (list, item, maxEntries) => {\n const target = Array.isArray(list) ? list : [];\n target.push(item);\n if (target.length > maxEntries) {\n target.splice(0, target.length - maxEntries);\n }\n return target;\n };\n\n const makeId = (prefix) => {\n const rand = Math.random().toString(36).slice(2, 10);\n return `${prefix}-${Date.now().toString(36)}-${rand}`;\n };\n\n const htmlEntityDecode = (value) => {\n const text = asString(value)\n .replace(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/"/gi, '\"')\n .replace(/'/gi, \"'\");\n return text;\n };\n\n const stripHtml = (value) => htmlEntityDecode(\n asString(value)\n .replace(//gi, ' ')\n .replace(//gi, ' ')\n .replace(//gi, ' ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n ).trim();\n\n const normalizeHeaders = (headers) => {\n if (!headers || typeof headers.entries !== 'function') {\n return {};\n }\n return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key).toLowerCase(), String(value)]));\n };\n\n const getActionLog = () => Array.isArray(workflowStaticData.actionLog) ? workflowStaticData.actionLog : [];\n const getApprovalQueue = () => Array.isArray(workflowStaticData.approvalQueue) ? workflowStaticData.approvalQueue : [];\n const getApprovalHistory = () => Array.isArray(workflowStaticData.approvalHistory) ? workflowStaticData.approvalHistory : [];\n const getInboundEvents = () => Array.isArray(workflowStaticData.inboundEvents) ? workflowStaticData.inboundEvents : [];\n const getEventDedup = () => Array.isArray(workflowStaticData.eventDedup) ? workflowStaticData.eventDedup : [];\n\n const persistActionLog = (entry) => {\n workflowStaticData.actionLog = pushRetained(getActionLog(), entry, maxLogEntries);\n };\n\n const logAction = (text, meta = undefined) => {\n persistActionLog({\n ts: now,\n source: 'openclaw-action',\n request_id: requestId,\n text,\n meta: isPlainObject(meta) ? meta : undefined,\n });\n };\n\n const enqueueApproval = ({ kind, summary, payload, tags = [] }) => {\n const item = {\n id: makeId('approval'),\n kind,\n status: 'pending',\n created_at: now,\n updated_at: now,\n request_id: requestId,\n summary,\n payload,\n tags: Array.isArray(tags) ? tags.filter(Boolean) : [],\n };\n workflowStaticData.approvalQueue = pushRetained(getApprovalQueue(), item, maxQueueEntries);\n return item;\n };\n\n const normalizeFetchResponse = ({ inputUrl, response, rawBody, maxChars }) => {\n const headers = normalizeHeaders(response.headers);\n const contentType = headers['content-type'] || '';\n const isHtml = /text\\/html|application\\/xhtml\\+xml/i.test(contentType);\n const isJson = /application\\/json|application\\/ld\\+json|application\\/problem\\+json/i.test(contentType);\n const titleMatch = isHtml ? rawBody.match(/]*>([\\s\\S]*?)<\\/title>/i) : null;\n const title = titleMatch ? previewText(stripHtml(titleMatch[1]), 200) : '';\n let bodyText = '';\n if (isHtml) {\n bodyText = stripHtml(rawBody);\n } else if (isJson) {\n try {\n bodyText = JSON.stringify(JSON.parse(rawBody), null, 2);\n } catch {\n bodyText = rawBody;\n }\n } else {\n bodyText = rawBody;\n }\n const normalized = bodyText.slice(0, maxChars);\n return {\n url: response.url || inputUrl,\n fetched_url: inputUrl,\n title,\n http_status: response.status,\n content_type: contentType || 'application/octet-stream',\n excerpt: previewText(normalized, 320),\n body_text: normalized,\n text_length: bodyText.length,\n truncated: bodyText.length > normalized.length,\n headers,\n };\n };\n\n const classifyInboundEvent = (eventArgs) => {\n const source = trimText(eventArgs.source || 'event');\n const type = trimText(eventArgs.type || 'event');\n const summary = previewText(eventArgs.summary || eventArgs.message || (isPlainObject(eventArgs.event) ? JSON.stringify(eventArgs.event) : ''), 280);\n const severity = trimText(eventArgs.severity || '').toLowerCase();\n const priority = trimText(eventArgs.priority || '').toLowerCase();\n const extraText = isPlainObject(eventArgs.event) ? JSON.stringify(eventArgs.event) : '';\n const haystack = `${summary} ${extraText}`.toLowerCase();\n let classification = 'watch';\n let shouldNotify = false;\n\n if (\n ['critical', 'urgent', 'sev1', 'sev2', 'high'].includes(severity) ||\n ['critical', 'urgent', 'p1', 'p2', 'high'].includes(priority) ||\n /(outage|down|failed|failure|critical|urgent|blocked|sev1|sev2|database unavailable|cannot reach|immediately|asap)/.test(haystack)\n ) {\n classification = 'urgent';\n shouldNotify = true;\n } else if (\n /(delivery|delivered|package|submission|alert|invoice|payment|review requested|build failed|incident|ticket)/.test(haystack)\n ) {\n classification = 'important';\n shouldNotify = true;\n }\n\n return { source, type, summary, severity, priority, classification, shouldNotify };\n };\n\n let route = 'respond';\n let statusCode = 400;\n let responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'unknown_action', message: 'action is not supported' },\n };\n let notifyText = '';\n\n if (action === 'append_log') {\n if (typeof args.text === 'string' && trimText(args.text).length > 0) {\n statusCode = 200;\n const record = {\n ts: now,\n source: trimText(args.source || 'openclaw-action'),\n request_id: requestId,\n text: trimText(args.text),\n meta: isPlainObject(args.meta) ? args.meta : undefined,\n };\n persistActionLog(record);\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'append_log',\n status: 'logged',\n preview: { text: record.text },\n sink: {\n type: 'workflow-static-data',\n key: 'actionLog',\n retained_entries: maxLogEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'required args are missing' },\n };\n }\n } else if (action === 'get_logs') {\n const actionLog = getActionLog();\n const limit = clamp(args.limit, 1, 50, 20);\n const entries = actionLog.slice(-limit).reverse();\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'get_logs',\n status: 'ok',\n count: entries.length,\n total_retained: actionLog.length,\n retained_entries: maxLogEntries,\n entries,\n },\n };\n } else if (action === 'send_notification_draft') {\n const title = trimText(args.title || '');\n const message = trimText(args.message || '');\n if (message) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'notification',\n summary: title ? `Notification draft \u2014 ${title}` : `Notification draft \u2014 ${previewText(message, 80)}`,\n payload: {\n title,\n message,\n },\n tags: ['notify', 'approval'],\n });\n logAction(`queued notification draft ${item.id}`, { pending_id: item.id, title_present: Boolean(title) });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_notification_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n preview: {\n title,\n message,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'send_notification_draft requires message' },\n };\n }\n } else if (action === 'notify') {\n if (typeof args.message === 'string' && trimText(args.message).length > 0) {\n route = 'notify';\n statusCode = 200;\n const title = trimText(args.title || '');\n notifyText = title ? `\ud83d\udd14 ${title}\\n${trimText(args.message)}` : `\ud83d\udd14 ${trimText(args.message)}`;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'notify',\n status: 'sent',\n preview: { title, message: trimText(args.message) },\n targets: ['telegram', 'discord'],\n },\n };\n logAction(`notify: ${previewText(`${title} ${args.message}`, 180)}`);\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'required args are missing' },\n };\n }\n } else if (action === 'send_email_draft') {\n const to = ensureStringArray(args.to);\n const cc = ensureStringArray(args.cc);\n const bcc = ensureStringArray(args.bcc);\n const subject = trimText(args.subject || '');\n const bodyText = trimText(args.body_text || '');\n const bodyHtml = trimText(args.body_html || '');\n\n if (to.length > 0 && subject && (bodyText || bodyHtml)) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_draft',\n summary: `Email draft to ${to.join(', ')} \u2014 ${subject}`,\n payload: {\n to,\n cc,\n bcc,\n subject,\n body_text: bodyText,\n body_html: bodyHtml,\n },\n tags: ['email', 'approval'],\n });\n logAction(`queued email draft ${item.id}`, { pending_id: item.id, subject, to_count: to.length });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_email_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n preview: {\n to,\n cc,\n bcc,\n subject,\n body_excerpt: previewText(bodyText || stripHtml(bodyHtml), 240),\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'send_email_draft requires to, subject, and body_text or body_html',\n },\n };\n }\n } else if (action === 'create_calendar_event') {\n const title = trimText(args.title || '');\n const start = trimText(args.start || '');\n const end = trimText(args.end || '');\n const startDate = start ? new Date(start) : null;\n const endDate = end ? new Date(end) : null;\n\n if (title && startDate && endDate && Number.isFinite(startDate.getTime()) && Number.isFinite(endDate.getTime()) && endDate > startDate) {\n statusCode = 200;\n const attendees = ensureStringArray(args.attendees);\n const item = enqueueApproval({\n kind: 'calendar_event',\n summary: `Calendar event ${title} @ ${startDate.toISOString()}`,\n payload: {\n calendar: trimText(args.calendar || 'primary') || 'primary',\n title,\n start: startDate.toISOString(),\n end: endDate.toISOString(),\n location: trimText(args.location || ''),\n description: trimText(args.description || ''),\n attendees,\n },\n tags: ['calendar', 'approval'],\n });\n logAction(`queued calendar event ${item.id}`, { pending_id: item.id, title, attendee_count: attendees.length });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'create_calendar_event',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n preview: {\n calendar: item.payload.calendar,\n title,\n start: item.payload.start,\n end: item.payload.end,\n location: item.payload.location,\n attendees,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'create_calendar_event requires title, start, and end with end after start',\n },\n };\n }\n } else if (action === 'approval_queue_add') {\n const kind = trimText(args.kind || 'manual');\n const summary = trimText(args.summary || '');\n if (summary) {\n statusCode = 200;\n const item = enqueueApproval({\n kind,\n summary,\n payload: isPlainObject(args.payload) ? args.payload : {},\n tags: ensureStringArray(args.tags),\n });\n logAction(`queued approval item ${item.id}`, { pending_id: item.id, kind });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_add',\n status: 'queued',\n pending_id: item.id,\n item,\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_queue_add requires summary' },\n };\n }\n } else if (action === 'approval_queue_list') {\n const limit = clamp(args.limit, 1, 50, 20);\n const includeHistory = args.include_history === true;\n const pending = getApprovalQueue().slice(-limit).reverse();\n const history = includeHistory ? getApprovalHistory().slice(-limit).reverse() : [];\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_list',\n status: 'ok',\n pending_count: getApprovalQueue().length,\n history_count: getApprovalHistory().length,\n pending,\n history,\n },\n };\n } else if (action === 'approval_queue_resolve') {\n const id = trimText(args.id || '');\n const decision = trimText(args.decision || '').toLowerCase();\n const queue = getApprovalQueue();\n const index = queue.findIndex((item) => item && item.id === id);\n\n if (!id || !['approve', 'reject'].includes(decision)) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_queue_resolve requires id and decision=approve|reject' },\n };\n } else if (index === -1) {\n statusCode = 404;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'not_found', message: 'approval queue item was not found' },\n };\n } else {\n statusCode = 200;\n const [item] = queue.splice(index, 1);\n workflowStaticData.approvalQueue = queue;\n const resolved = {\n ...item,\n status: decision === 'approve' ? 'approved' : 'rejected',\n resolved_at: now,\n resolution_note: trimText(args.note || ''),\n };\n\n let executed = false;\n let executed_action = '';\n if (decision === 'approve' && resolved.kind === 'notification' && isPlainObject(resolved.payload)) {\n const title = trimText(resolved.payload.title || '');\n const message = trimText(resolved.payload.message || '');\n if (message) {\n route = 'notify';\n notifyText = title ? `\ud83d\udd14 ${title}\n${message}` : `\ud83d\udd14 ${message}`;\n executed = true;\n executed_action = 'notify';\n resolved.execution = {\n action: 'notify',\n status: 'sent',\n executed_at: now,\n };\n }\n }\n\n workflowStaticData.approvalHistory = pushRetained(getApprovalHistory(), resolved, maxHistoryEntries);\n logAction(`resolved approval item ${id}`, { pending_id: id, status: resolved.status, executed, executed_action });\n if (!executed && args.notify_on_resolve === true) {\n route = 'notify';\n const emoji = resolved.status === 'approved' ? '\u2705' : '\ud83d\uded1';\n notifyText = `${emoji} Approval ${resolved.status}\n${resolved.summary}`;\n }\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_resolve',\n status: resolved.status,\n executed,\n executed_action,\n item: resolved,\n },\n };\n }\n } else if (action === 'approval_history_attach_execution') {\n const id = trimText(args.id || '');\n const execution = isPlainObject(args.execution) ? args.execution : null;\n const history = getApprovalHistory();\n const index = history.findIndex((item) => item && item.id === id);\n\n if (!id || !execution) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_history_attach_execution requires id and execution object' },\n };\n } else if (index === -1) {\n statusCode = 404;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'not_found', message: 'approval history item was not found' },\n };\n } else {\n statusCode = 200;\n const existing = history[index];\n const updated = {\n ...existing,\n updated_at: now,\n execution: {\n ...(isPlainObject(existing.execution) ? existing.execution : {}),\n ...execution,\n updated_at: now,\n },\n };\n history[index] = updated;\n workflowStaticData.approvalHistory = history;\n logAction(`attached execution for approval item ${id}`, { pending_id: id, execution_status: updated.execution.status || '' });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_history_attach_execution',\n status: 'updated',\n item: updated,\n },\n };\n }\n } else if (action === 'fetch_and_normalize_url') {\n const url = trimText(args.url || '');\n const maxChars = clamp(args.max_chars, 500, 20000, 8000);\n const timeoutMs = clamp(args.timeout_ms, 1000, 30000, 10000);\n\n if (!/^https?:\\/\\//i.test(url)) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'fetch_and_normalize_url requires an http or https url' },\n };\n } else {\n try {\n if (!this || !this.helpers || typeof this.helpers.httpRequest !== 'function') {\n throw new Error('this.helpers.httpRequest is not available in this n8n Code runtime');\n }\n const extraHeaders = isPlainObject(args.headers) ? args.headers : {};\n const requestHeaders = Object.fromEntries(\n Object.entries(extraHeaders)\n .filter(([key, value]) => typeof key === 'string' && typeof value === 'string' && key.trim() && value.trim())\n .map(([key, value]) => [key.trim(), value.trim()])\n );\n\n const skipSslCertificateValidation = args.skip_ssl_certificate_validation === true;\n const upstream = await this.helpers.httpRequest({\n url,\n method: 'GET',\n headers: requestHeaders,\n timeout: timeoutMs,\n json: false,\n encoding: 'text',\n returnFullResponse: true,\n skipSslCertificateValidation,\n });\n\n const headersObj = Object.fromEntries(\n Object.entries(upstream.headers || {}).map(([key, value]) => [\n String(key).toLowerCase(),\n Array.isArray(value) ? value.join(', ') : String(value ?? ''),\n ])\n );\n const response = {\n ok: (upstream.statusCode || 0) >= 200 && (upstream.statusCode || 0) < 300,\n status: upstream.statusCode || 0,\n url: upstream.request?.uri?.href || upstream.request?.href || upstream.url || url,\n headers: { entries: () => Object.entries(headersObj) },\n };\n const rawBody = typeof upstream.body === 'string'\n ? upstream.body\n : JSON.stringify(upstream.body ?? '', null, 2);\n const normalized = normalizeFetchResponse({ inputUrl: url, response, rawBody, maxChars });\n if (!response.ok) {\n statusCode = 502;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'upstream_http_error', message: `upstream returned ${response.status}` },\n result: {\n action: 'fetch_and_normalize_url',\n upstream: normalized,\n },\n };\n } else {\n statusCode = 200;\n logAction(`fetched url ${normalized.url}`, { url: normalized.url, content_type: normalized.content_type, http_status: normalized.http_status });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'fetch_and_normalize_url',\n status: 'ok',\n ...normalized,\n },\n };\n }\n } catch (error) {\n statusCode = 502;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: error && error.name === 'AbortError' ? 'upstream_timeout' : 'fetch_failed',\n message: error && error.message ? String(error.message) : 'fetch failed',\n },\n };\n }\n }\n } else if (action === 'inbound_event_filter') {\n const classified = classifyInboundEvent(args);\n if (!classified.summary) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'inbound_event_filter requires summary or message' },\n };\n } else {\n const dedupeKey = trimText(args.dedupe_key || `${classified.source}|${classified.type}|${classified.summary}`.toLowerCase().replace(/\\s+/g, ' '));\n const dedupList = getEventDedup();\n const duplicate = dedupList.includes(dedupeKey);\n if (!duplicate) {\n workflowStaticData.eventDedup = pushRetained(dedupList, dedupeKey, maxDedupEntries);\n }\n const eventRecord = {\n ts: now,\n source: classified.source,\n type: classified.type,\n summary: classified.summary,\n severity: classified.severity,\n priority: classified.priority,\n classification: duplicate ? 'deduped' : classified.classification,\n dedupe_key: dedupeKey,\n duplicate,\n event: isPlainObject(args.event) ? args.event : undefined,\n };\n workflowStaticData.inboundEvents = pushRetained(getInboundEvents(), eventRecord, maxEventEntries);\n const notifyAllowed = args.notify !== false;\n const shouldNotify = classified.shouldNotify && !duplicate && notifyAllowed;\n if (shouldNotify) {\n route = 'notify';\n const emoji = classified.classification === 'urgent' ? '\ud83d\udea8' : '\u2139\ufe0f';\n notifyText = `${emoji} ${classified.source}: ${classified.summary}`;\n }\n logAction(`ingested inbound event ${classified.source}`, { classification: eventRecord.classification, duplicate });\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'inbound_event_filter',\n status: duplicate ? 'deduped' : 'stored',\n classification: eventRecord.classification,\n duplicate,\n notified: shouldNotify,\n event: eventRecord,\n sink: {\n type: 'workflow-static-data',\n key: 'inboundEvents',\n retained_entries: maxEventEntries,\n },\n },\n };\n }\n }\n\n return {\n json: {\n route,\n status_code: statusCode,\n response_body: responseBody,\n notify_text: notifyText,\n },\n };\n})();\n" + "jsCode": "return (async () => {\n const body = ($json.body && typeof $json.body === 'object') ? $json.body : $json;\n const action = typeof body.action === 'string' ? body.action : '';\n const args = (body.args && typeof body.args === 'object' && body.args !== null) ? body.args : {};\n const requestId = typeof body.request_id === 'string' ? body.request_id : '';\n const now = new Date().toISOString();\n const workflowStaticData = $getWorkflowStaticData('global');\n const maxLogEntries = 200;\n const maxQueueEntries = 200;\n const maxHistoryEntries = 200;\n const maxEventEntries = 200;\n const maxDedupEntries = 500;\n\n const clamp = (value, min, max, fallback) => {\n const num = Number(value);\n if (!Number.isFinite(num)) {\n return fallback;\n }\n return Math.max(min, Math.min(max, Math.trunc(num)));\n };\n\n const asString = (value, fallback = '') => (typeof value === 'string' ? value : fallback);\n\n const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);\n\n const trimText = (value) => asString(value).trim();\n\n const previewText = (value, max = 160) => {\n const text = trimText(value).replace(/\\s+/g, ' ');\n if (text.length <= max) {\n return text;\n }\n return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`;\n };\n\n const ensureStringArray = (value) => {\n if (Array.isArray(value)) {\n return value.map((entry) => trimText(entry)).filter(Boolean);\n }\n if (typeof value === 'string') {\n const trimmed = trimText(value);\n return trimmed ? [trimmed] : [];\n }\n return [];\n };\n\n const pushRetained = (list, item, maxEntries) => {\n const target = Array.isArray(list) ? list : [];\n target.push(item);\n if (target.length > maxEntries) {\n target.splice(0, target.length - maxEntries);\n }\n return target;\n };\n\n const makeId = (prefix) => {\n const rand = Math.random().toString(36).slice(2, 10);\n return `${prefix}-${Date.now().toString(36)}-${rand}`;\n };\n\n const htmlEntityDecode = (value) => {\n const text = asString(value)\n .replace(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/"/gi, '\"')\n .replace(/'/gi, \"'\");\n return text;\n };\n\n const stripHtml = (value) => htmlEntityDecode(\n asString(value)\n .replace(//gi, ' ')\n .replace(//gi, ' ')\n .replace(//gi, ' ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n ).trim();\n\n const normalizeHeaders = (headers) => {\n if (!headers || typeof headers.entries !== 'function') {\n return {};\n }\n return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key).toLowerCase(), String(value)]));\n };\n\n const getActionLog = () => Array.isArray(workflowStaticData.actionLog) ? workflowStaticData.actionLog : [];\n const getApprovalQueue = () => Array.isArray(workflowStaticData.approvalQueue) ? workflowStaticData.approvalQueue : [];\n const getApprovalHistory = () => Array.isArray(workflowStaticData.approvalHistory) ? workflowStaticData.approvalHistory : [];\n const getInboundEvents = () => Array.isArray(workflowStaticData.inboundEvents) ? workflowStaticData.inboundEvents : [];\n const getEventDedup = () => Array.isArray(workflowStaticData.eventDedup) ? workflowStaticData.eventDedup : [];\n\n const persistActionLog = (entry) => {\n workflowStaticData.actionLog = pushRetained(getActionLog(), entry, maxLogEntries);\n };\n\n const logAction = (text, meta = undefined) => {\n persistActionLog({\n ts: now,\n source: 'openclaw-action',\n request_id: requestId,\n text,\n meta: isPlainObject(meta) ? meta : undefined,\n });\n };\n\n const enqueueApproval = ({ kind, summary, payload, tags = [] }) => {\n const item = {\n id: makeId('approval'),\n kind,\n status: 'pending',\n created_at: now,\n updated_at: now,\n request_id: requestId,\n summary,\n payload,\n tags: Array.isArray(tags) ? tags.filter(Boolean) : [],\n };\n workflowStaticData.approvalQueue = pushRetained(getApprovalQueue(), item, maxQueueEntries);\n return item;\n };\n\n const makeApprovalPolicy = (mutationLevel = 'none') => ({\n policy: 'approval_queue_resolve',\n required: mutationLevel !== 'none',\n mutation_level: mutationLevel,\n reason: mutationLevel !== 'none'\n ? 'requires explicit approval before host-side Google action'\n : 'read_only_action',\n });\n\n const normalizeFetchResponse = ({ inputUrl, response, rawBody, maxChars }) => {\n const headers = normalizeHeaders(response.headers);\n const contentType = headers['content-type'] || '';\n const isHtml = /text\\/html|application\\/xhtml\\+xml/i.test(contentType);\n const isJson = /application\\/json|application\\/ld\\+json|application\\/problem\\+json/i.test(contentType);\n const titleMatch = isHtml ? rawBody.match(/]*>([\\s\\S]*?)<\\/title>/i) : null;\n const title = titleMatch ? previewText(stripHtml(titleMatch[1]), 200) : '';\n let bodyText = '';\n if (isHtml) {\n bodyText = stripHtml(rawBody);\n } else if (isJson) {\n try {\n bodyText = JSON.stringify(JSON.parse(rawBody), null, 2);\n } catch {\n bodyText = rawBody;\n }\n } else {\n bodyText = rawBody;\n }\n const normalized = bodyText.slice(0, maxChars);\n return {\n url: response.url || inputUrl,\n fetched_url: inputUrl,\n title,\n http_status: response.status,\n content_type: contentType || 'application/octet-stream',\n excerpt: previewText(normalized, 320),\n body_text: normalized,\n text_length: bodyText.length,\n truncated: bodyText.length > normalized.length,\n headers,\n };\n };\n\n const classifyInboundEvent = (eventArgs) => {\n const source = trimText(eventArgs.source || 'event');\n const type = trimText(eventArgs.type || 'event');\n const summary = previewText(eventArgs.summary || eventArgs.message || (isPlainObject(eventArgs.event) ? JSON.stringify(eventArgs.event) : ''), 280);\n const severity = trimText(eventArgs.severity || '').toLowerCase();\n const priority = trimText(eventArgs.priority || '').toLowerCase();\n const extraText = isPlainObject(eventArgs.event) ? JSON.stringify(eventArgs.event) : '';\n const haystack = `${summary} ${extraText}`.toLowerCase();\n let classification = 'watch';\n let shouldNotify = false;\n\n if (\n ['critical', 'urgent', 'sev1', 'sev2', 'high'].includes(severity) ||\n ['critical', 'urgent', 'p1', 'p2', 'high'].includes(priority) ||\n /(outage|down|failed|failure|critical|urgent|blocked|sev1|sev2|database unavailable|cannot reach|immediately|asap)/.test(haystack)\n ) {\n classification = 'urgent';\n shouldNotify = true;\n } else if (\n /(delivery|delivered|package|submission|alert|invoice|payment|review requested|build failed|incident|ticket)/.test(haystack)\n ) {\n classification = 'important';\n shouldNotify = true;\n }\n\n return { source, type, summary, severity, priority, classification, shouldNotify };\n };\n\n let route = 'respond';\n let statusCode = 400;\n let responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'unknown_action', message: 'action is not supported' },\n };\n let notifyText = '';\n\n if (action === 'append_log') {\n if (typeof args.text === 'string' && trimText(args.text).length > 0) {\n statusCode = 200;\n const record = {\n ts: now,\n source: trimText(args.source || 'openclaw-action'),\n request_id: requestId,\n text: trimText(args.text),\n meta: isPlainObject(args.meta) ? args.meta : undefined,\n };\n persistActionLog(record);\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'append_log',\n status: 'logged',\n preview: { text: record.text },\n sink: {\n type: 'workflow-static-data',\n key: 'actionLog',\n retained_entries: maxLogEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'required args are missing' },\n };\n }\n } else if (action === 'get_logs') {\n const actionLog = getActionLog();\n const limit = clamp(args.limit, 1, 50, 20);\n const entries = actionLog.slice(-limit).reverse();\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'get_logs',\n status: 'ok',\n count: entries.length,\n total_retained: actionLog.length,\n retained_entries: maxLogEntries,\n entries,\n },\n };\n } else if (action === 'send_notification_draft') {\n const title = trimText(args.title || '');\n const message = trimText(args.message || '');\n if (message) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'notification',\n summary: title ? `Notification draft — ${title}` : `Notification draft — ${previewText(message, 80)}`,\n payload: {\n title,\n message,\n },\n tags: ['notify', 'approval'],\n });\n logAction(`queued notification draft ${item.id}`, { pending_id: item.id, title_present: Boolean(title) });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_notification_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n title,\n message,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'send_notification_draft requires message' },\n };\n }\n } else if (action === 'notify') {\n if (typeof args.message === 'string' && trimText(args.message).length > 0) {\n route = 'notify';\n statusCode = 200;\n const title = trimText(args.title || '');\n notifyText = title ? `🔔 ${title}\\n${trimText(args.message)}` : `🔔 ${trimText(args.message)}`;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'notify',\n status: 'sent',\n preview: { title, message: trimText(args.message) },\n targets: ['telegram', 'discord'],\n },\n };\n logAction(`notify: ${previewText(`${title} ${args.message}`, 180)}`);\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'required args are missing' },\n };\n }\n } else if (action === 'send_email_draft') {\n const to = ensureStringArray(args.to);\n const cc = ensureStringArray(args.cc);\n const bcc = ensureStringArray(args.bcc);\n const subject = trimText(args.subject || '');\n const bodyText = trimText(args.body_text || '');\n const bodyHtml = trimText(args.body_html || '');\n\n if (to.length > 0 && subject && (bodyText || bodyHtml)) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_draft',\n summary: `Email draft to ${to.join(', ')} — ${subject}`,\n payload: {\n to,\n cc,\n bcc,\n subject,\n body_text: bodyText,\n body_html: bodyHtml,\n },\n tags: ['email', 'approval'],\n });\n logAction(`queued email draft ${item.id}`, { pending_id: item.id, subject, to_count: to.length });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_email_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n to,\n cc,\n bcc,\n subject,\n body_excerpt: previewText(bodyText || stripHtml(bodyHtml), 240),\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'send_email_draft requires to, subject, and body_text or body_html',\n },\n };\n }\n } else if (action === 'list_email_drafts') {\n const max = clamp(args.max, 1, 100, 20);\n const page = trimText(args.page || '');\n const all = args.all === true;\n const failEmpty = args.fail_empty === true;\n\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_list_drafts',\n summary: all ? 'List Gmail drafts (all pages)' : `List Gmail drafts (max ${max})`,\n payload: {\n max,\n page,\n all,\n fail_empty: failEmpty,\n },\n tags: ['email', 'read', 'approval'],\n });\n logAction(`queued list_email_drafts ${item.id}`, { pending_id: item.id, max, all });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'list_email_drafts',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('low'),\n preview: {\n max,\n page,\n all,\n fail_empty: failEmpty,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else if (action === 'delete_email_draft') {\n const draftId = trimText(args.draft_id || args.id || '');\n\n if (draftId) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_draft_delete',\n summary: `Delete Gmail draft ${draftId}`,\n payload: {\n draft_id: draftId,\n },\n tags: ['email', 'approval', 'mutating', 'destructive'],\n });\n logAction(`queued delete_email_draft ${item.id}`, { pending_id: item.id, draft_id: draftId });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'delete_email_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n draft_id: draftId,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'delete_email_draft requires draft_id',\n },\n };\n }\n } else if (action === 'send_gmail_draft' || action === 'send_approved_email') {\n const requestedAction = action;\n const draftId = trimText(args.draft_id || args.id || '');\n\n if (draftId) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_draft_send',\n summary: `Send Gmail draft ${draftId}`,\n payload: {\n draft_id: draftId,\n requested_action: requestedAction,\n },\n tags: ['email', 'approval', 'mutating'],\n });\n logAction(`queued ${requestedAction} ${item.id}`, { pending_id: item.id, draft_id: draftId });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_gmail_draft',\n requested_action: requestedAction,\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n draft_id: draftId,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'send_gmail_draft requires draft_id',\n },\n };\n }\n } else if (action === 'create_calendar_event') {\n const title = trimText(args.title || '');\n const start = trimText(args.start || '');\n const end = trimText(args.end || '');\n const startDate = start ? new Date(start) : null;\n const endDate = end ? new Date(end) : null;\n\n if (title && startDate && endDate && Number.isFinite(startDate.getTime()) && Number.isFinite(endDate.getTime()) && endDate > startDate) {\n statusCode = 200;\n const attendees = ensureStringArray(args.attendees);\n const item = enqueueApproval({\n kind: 'calendar_event',\n summary: `Calendar event ${title} @ ${startDate.toISOString()}`,\n payload: {\n calendar: trimText(args.calendar || 'primary') || 'primary',\n title,\n start: startDate.toISOString(),\n end: endDate.toISOString(),\n location: trimText(args.location || ''),\n description: trimText(args.description || ''),\n attendees,\n },\n tags: ['calendar', 'approval'],\n });\n logAction(`queued calendar event ${item.id}`, { pending_id: item.id, title, attendee_count: attendees.length });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'create_calendar_event',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n calendar: item.payload.calendar,\n title,\n start: item.payload.start,\n end: item.payload.end,\n location: item.payload.location,\n attendees,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'create_calendar_event requires title, start, and end with end after start',\n },\n };\n }\n } else if (action === 'approval_queue_add') {\n const kind = trimText(args.kind || 'manual');\n const summary = trimText(args.summary || '');\n if (summary) {\n statusCode = 200;\n const item = enqueueApproval({\n kind,\n summary,\n payload: isPlainObject(args.payload) ? args.payload : {},\n tags: ensureStringArray(args.tags),\n });\n logAction(`queued approval item ${item.id}`, { pending_id: item.id, kind });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_add',\n status: 'queued',\n pending_id: item.id,\n item,\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_queue_add requires summary' },\n };\n }\n } else if (action === 'approval_queue_list') {\n const limit = clamp(args.limit, 1, 50, 20);\n const includeHistory = args.include_history === true;\n const pending = getApprovalQueue().slice(-limit).reverse();\n const history = includeHistory ? getApprovalHistory().slice(-limit).reverse() : [];\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_list',\n status: 'ok',\n pending_count: getApprovalQueue().length,\n history_count: getApprovalHistory().length,\n pending,\n history,\n },\n };\n } else if (action === 'approval_queue_resolve') {\n const id = trimText(args.id || '');\n const decision = trimText(args.decision || '').toLowerCase();\n const queue = getApprovalQueue();\n const index = queue.findIndex((item) => item && item.id === id);\n\n if (!id || !['approve', 'reject'].includes(decision)) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_queue_resolve requires id and decision=approve|reject' },\n };\n } else if (index === -1) {\n statusCode = 404;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'not_found', message: 'approval queue item was not found' },\n };\n } else {\n statusCode = 200;\n const [item] = queue.splice(index, 1);\n workflowStaticData.approvalQueue = queue;\n const resolved = {\n ...item,\n status: decision === 'approve' ? 'approved' : 'rejected',\n resolved_at: now,\n resolution_note: trimText(args.note || ''),\n };\n\n let executed = false;\n let executed_action = '';\n if (decision === 'approve' && resolved.kind === 'notification' && isPlainObject(resolved.payload)) {\n const title = trimText(resolved.payload.title || '');\n const message = trimText(resolved.payload.message || '');\n if (message) {\n route = 'notify';\n notifyText = title ? `🔔 ${title}\n${message}` : `🔔 ${message}`;\n executed = true;\n executed_action = 'notify';\n resolved.execution = {\n action: 'notify',\n status: 'sent',\n executed_at: now,\n };\n }\n }\n\n workflowStaticData.approvalHistory = pushRetained(getApprovalHistory(), resolved, maxHistoryEntries);\n logAction(`resolved approval item ${id}`, { pending_id: id, status: resolved.status, executed, executed_action });\n if (!executed && args.notify_on_resolve === true) {\n route = 'notify';\n const emoji = resolved.status === 'approved' ? '✅' : '🛑';\n notifyText = `${emoji} Approval ${resolved.status}\n${resolved.summary}`;\n }\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_resolve',\n status: resolved.status,\n executed,\n executed_action,\n item: resolved,\n },\n };\n }\n } else if (action === 'approval_history_attach_execution') {\n const id = trimText(args.id || '');\n const execution = isPlainObject(args.execution) ? args.execution : null;\n const history = getApprovalHistory();\n const index = history.findIndex((item) => item && item.id === id);\n\n if (!id || !execution) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_history_attach_execution requires id and execution object' },\n };\n } else if (index === -1) {\n statusCode = 404;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'not_found', message: 'approval history item was not found' },\n };\n } else {\n statusCode = 200;\n const existing = history[index];\n const updated = {\n ...existing,\n updated_at: now,\n execution: {\n ...(isPlainObject(existing.execution) ? existing.execution : {}),\n ...execution,\n updated_at: now,\n },\n };\n history[index] = updated;\n workflowStaticData.approvalHistory = history;\n logAction(`attached execution for approval item ${id}`, { pending_id: id, execution_status: updated.execution.status || '' });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_history_attach_execution',\n status: 'updated',\n item: updated,\n },\n };\n }\n } else if (action === 'fetch_and_normalize_url') {\n const url = trimText(args.url || '');\n const maxChars = clamp(args.max_chars, 500, 20000, 8000);\n const timeoutMs = clamp(args.timeout_ms, 1000, 30000, 10000);\n\n if (!/^https?:\\/\\//i.test(url)) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'fetch_and_normalize_url requires an http or https url' },\n };\n } else {\n try {\n if (!this || !this.helpers || typeof this.helpers.httpRequest !== 'function') {\n throw new Error('this.helpers.httpRequest is not available in this n8n Code runtime');\n }\n const extraHeaders = isPlainObject(args.headers) ? args.headers : {};\n const requestHeaders = Object.fromEntries(\n Object.entries(extraHeaders)\n .filter(([key, value]) => typeof key === 'string' && typeof value === 'string' && key.trim() && value.trim())\n .map(([key, value]) => [key.trim(), value.trim()])\n );\n\n const skipSslCertificateValidation = args.skip_ssl_certificate_validation === true;\n const upstream = await this.helpers.httpRequest({\n url,\n method: 'GET',\n headers: requestHeaders,\n timeout: timeoutMs,\n json: false,\n encoding: 'text',\n returnFullResponse: true,\n skipSslCertificateValidation,\n });\n\n const headersObj = Object.fromEntries(\n Object.entries(upstream.headers || {}).map(([key, value]) => [\n String(key).toLowerCase(),\n Array.isArray(value) ? value.join(', ') : String(value ?? ''),\n ])\n );\n const response = {\n ok: (upstream.statusCode || 0) >= 200 && (upstream.statusCode || 0) < 300,\n status: upstream.statusCode || 0,\n url: upstream.request?.uri?.href || upstream.request?.href || upstream.url || url,\n headers: { entries: () => Object.entries(headersObj) },\n };\n const rawBody = typeof upstream.body === 'string'\n ? upstream.body\n : JSON.stringify(upstream.body ?? '', null, 2);\n const normalized = normalizeFetchResponse({ inputUrl: url, response, rawBody, maxChars });\n if (!response.ok) {\n statusCode = 502;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'upstream_http_error', message: `upstream returned ${response.status}` },\n result: {\n action: 'fetch_and_normalize_url',\n upstream: normalized,\n },\n };\n } else {\n statusCode = 200;\n logAction(`fetched url ${normalized.url}`, { url: normalized.url, content_type: normalized.content_type, http_status: normalized.http_status });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'fetch_and_normalize_url',\n status: 'ok',\n ...normalized,\n },\n };\n }\n } catch (error) {\n statusCode = 502;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: error && error.name === 'AbortError' ? 'upstream_timeout' : 'fetch_failed',\n message: error && error.message ? String(error.message) : 'fetch failed',\n },\n };\n }\n }\n } else if (action === 'inbound_event_filter') {\n const classified = classifyInboundEvent(args);\n if (!classified.summary) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'inbound_event_filter requires summary or message' },\n };\n } else {\n const dedupeKey = trimText(args.dedupe_key || `${classified.source}|${classified.type}|${classified.summary}`.toLowerCase().replace(/\\s+/g, ' '));\n const dedupList = getEventDedup();\n const duplicate = dedupList.includes(dedupeKey);\n if (!duplicate) {\n workflowStaticData.eventDedup = pushRetained(dedupList, dedupeKey, maxDedupEntries);\n }\n const eventRecord = {\n ts: now,\n source: classified.source,\n type: classified.type,\n summary: classified.summary,\n severity: classified.severity,\n priority: classified.priority,\n classification: duplicate ? 'deduped' : classified.classification,\n dedupe_key: dedupeKey,\n duplicate,\n event: isPlainObject(args.event) ? args.event : undefined,\n };\n workflowStaticData.inboundEvents = pushRetained(getInboundEvents(), eventRecord, maxEventEntries);\n const notifyAllowed = args.notify !== false;\n const shouldNotify = classified.shouldNotify && !duplicate && notifyAllowed;\n if (shouldNotify) {\n route = 'notify';\n const emoji = classified.classification === 'urgent' ? '🚨' : 'ℹ️';\n notifyText = `${emoji} ${classified.source}: ${classified.summary}`;\n }\n logAction(`ingested inbound event ${classified.source}`, { classification: eventRecord.classification, duplicate });\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'inbound_event_filter',\n status: duplicate ? 'deduped' : 'stored',\n classification: eventRecord.classification,\n duplicate,\n notified: shouldNotify,\n event: eventRecord,\n sink: {\n type: 'workflow-static-data',\n key: 'inboundEvents',\n retained_entries: maxEventEntries,\n },\n },\n };\n }\n }\n\n return {\n json: {\n route,\n status_code: statusCode,\n response_body: responseBody,\n notify_text: notifyText,\n },\n };\n})();\n" } }, { @@ -214,5 +214,5 @@ "note": "After import, set Webhook authentication to Header Auth and bind a local credential using x-openclaw-secret. This asset ships append_log + get_logs via workflow static data plus Telegram/Discord notify fan-out." }, "active": false, - "versionId": "openclaw-action-v6" + "versionId": "openclaw-action-v7" } diff --git a/skills/n8n-webhook/assets/test-delete-email-draft.json b/skills/n8n-webhook/assets/test-delete-email-draft.json new file mode 100644 index 0000000..2654ff1 --- /dev/null +++ b/skills/n8n-webhook/assets/test-delete-email-draft.json @@ -0,0 +1,7 @@ +{ + "action": "delete_email_draft", + "request_id": "test-delete-email-draft-001", + "args": { + "draft_id": "r-example-draft-id" + } +} diff --git a/skills/n8n-webhook/assets/test-list-email-drafts.json b/skills/n8n-webhook/assets/test-list-email-drafts.json new file mode 100644 index 0000000..fd9e76a --- /dev/null +++ b/skills/n8n-webhook/assets/test-list-email-drafts.json @@ -0,0 +1,8 @@ +{ + "action": "list_email_drafts", + "request_id": "test-list-email-drafts-001", + "args": { + "max": 10, + "all": false + } +} diff --git a/skills/n8n-webhook/assets/test-send-approved-email.json b/skills/n8n-webhook/assets/test-send-approved-email.json new file mode 100644 index 0000000..daf9dc4 --- /dev/null +++ b/skills/n8n-webhook/assets/test-send-approved-email.json @@ -0,0 +1,7 @@ +{ + "action": "send_approved_email", + "request_id": "test-send-approved-email-001", + "args": { + "draft_id": "r-example-draft-id" + } +} diff --git a/skills/n8n-webhook/assets/test-send-gmail-draft.json b/skills/n8n-webhook/assets/test-send-gmail-draft.json new file mode 100644 index 0000000..d900d34 --- /dev/null +++ b/skills/n8n-webhook/assets/test-send-gmail-draft.json @@ -0,0 +1,7 @@ +{ + "action": "send_gmail_draft", + "request_id": "test-send-gmail-draft-001", + "args": { + "draft_id": "r-example-draft-id" + } +} diff --git a/skills/n8n-webhook/references/openclaw-action.md b/skills/n8n-webhook/references/openclaw-action.md index df305a8..eb293b6 100644 --- a/skills/n8n-webhook/references/openclaw-action.md +++ b/skills/n8n-webhook/references/openclaw-action.md @@ -16,6 +16,9 @@ It implements a real local OpenClaw → n8n router. - `notify` - `send_notification_draft` - `send_email_draft` + - `list_email_drafts` + - `delete_email_draft` + - `send_gmail_draft` (alias: `send_approved_email`) - `create_calendar_event` - `approval_queue_add` - `approval_queue_list` @@ -49,13 +52,30 @@ Example stored record: - when resolved with `decision=approve`, it executes the existing `notify` path and sends through Telegram + Discord - uses only the already-configured notification credentials in the live n8n instance -### `send_email_draft` and `create_calendar_event` +### Gmail + Calendar approval-gated actions -- queue approval-gated proposals into workflow static data under key: +Actions: +- `send_email_draft` +- `list_email_drafts` +- `delete_email_draft` +- `send_gmail_draft` (alias: `send_approved_email`) +- `create_calendar_event` + +Behavior: +- queue proposals into workflow static data under key: - `approvalQueue` - keep the most recent `200` pending entries -- do **not** send email or create provider-side calendar events in the shipped starter workflow -- are designed to become safe provider-backed executors later once instance-local creds are bound in n8n +- return explicit approval policy metadata per action (`approval.policy`, `approval.required`, `approval.mutation_level`) +- do **not** execute Gmail/Calendar side effects directly in the shipped starter workflow +- are intended for host-side execution via the included `gog` bridge after explicit approval resolution + +Approval policy defaults: +- `send_email_draft`, `delete_email_draft`, `send_gmail_draft` / `send_approved_email`, `create_calendar_event` + - `approval.required = true` + - `approval.mutation_level = "high"` +- `list_email_drafts` + - `approval.required = true` + - `approval.mutation_level = "low"` (read-only action, still routed through approval queue for explicit operator acknowledgement + audit trail) ### `approval_queue_resolve` @@ -147,6 +167,10 @@ After import, set this manually in n8n: - `assets/test-notify.json` - `assets/test-send-notification-draft.json` - `assets/test-send-email-draft.json` +- `assets/test-list-email-drafts.json` +- `assets/test-delete-email-draft.json` +- `assets/test-send-gmail-draft.json` +- `assets/test-send-approved-email.json` - `assets/test-create-calendar-event.json` - `assets/test-fetch-and-normalize-url.json` - `assets/test-approval-queue-list.json` @@ -161,6 +185,10 @@ scripts/call-action.sh get_logs --args '{"limit":5}' --pretty scripts/call-action.sh notify --args '{"title":"Workflow finished","message":"n8n router test"}' --pretty scripts/call-action.sh send_notification_draft --args-file assets/test-send-notification-draft.json --pretty scripts/call-action.sh send_email_draft --args-file assets/test-send-email-draft.json --pretty +scripts/call-action.sh list_email_drafts --args-file assets/test-list-email-drafts.json --pretty +scripts/call-action.sh delete_email_draft --args-file assets/test-delete-email-draft.json --pretty +scripts/call-action.sh send_gmail_draft --args-file assets/test-send-gmail-draft.json --pretty +scripts/call-action.sh send_approved_email --args-file assets/test-send-approved-email.json --pretty scripts/call-action.sh create_calendar_event --args-file assets/test-create-calendar-event.json --pretty scripts/call-action.sh fetch_and_normalize_url --args '{"url":"http://192.168.153.113:18808/healthz"}' --pretty scripts/call-action.sh fetch_and_normalize_url --args '{"url":"https://example.com","skip_ssl_certificate_validation":true}' --pretty @@ -201,6 +229,57 @@ python3 scripts/resolve-approval-with-gog.py --id --decision appro } ``` +### list_email_drafts + +```json +{ + "ok": true, + "request_id": "test-list-email-drafts-001", + "result": { + "action": "list_email_drafts", + "status": "queued_for_approval", + "pending_id": "approval-ghi789", + "approval_status": "pending", + "approval": { + "policy": "approval_queue_resolve", + "required": true, + "mutation_level": "low" + } + } +} +``` + +### delete_email_draft + +```json +{ + "ok": true, + "request_id": "test-delete-email-draft-001", + "result": { + "action": "delete_email_draft", + "status": "queued_for_approval", + "pending_id": "approval-jkl012", + "approval_status": "pending" + } +} +``` + +### send_gmail_draft / send_approved_email + +```json +{ + "ok": true, + "request_id": "test-send-gmail-draft-001", + "result": { + "action": "send_gmail_draft", + "requested_action": "send_gmail_draft", + "status": "queued_for_approval", + "pending_id": "approval-mno345", + "approval_status": "pending" + } +} +``` + ### create_calendar_event ```json @@ -256,6 +335,9 @@ Behavior: - resolves an approval item through `openclaw-action` - executes supported kinds on the host: - `email_draft` → `gog gmail drafts create` + - `email_list_drafts` → `gog gmail drafts list` + - `email_draft_delete` → `gog gmail drafts delete` + - `email_draft_send` → `gog gmail drafts send` - `calendar_event` → `gog calendar create` - writes execution metadata back via `approval_history_attach_execution` diff --git a/skills/n8n-webhook/references/payloads.md b/skills/n8n-webhook/references/payloads.md index 160c5c8..dbd4371 100644 --- a/skills/n8n-webhook/references/payloads.md +++ b/skills/n8n-webhook/references/payloads.md @@ -150,11 +150,76 @@ Purpose: - queue an email draft proposal for approval - does **not** send mail directly in the shipped starter workflow +Approval policy: +- required: `true` +- mutation level: `high` + Sink: - type: `workflow-static-data` - key: `approvalQueue` - retained entries: `200` +### `list_email_drafts` + +Request: + +```json +{ + "action": "list_email_drafts", + "args": { + "max": 20, + "all": false + } +} +``` + +Purpose: +- queue a host-side Gmail draft listing request for approval/audit + +Approval policy: +- required: `true` +- mutation level: `low` (read-only) + +### `delete_email_draft` + +Request: + +```json +{ + "action": "delete_email_draft", + "args": { + "draft_id": "r-example-draft-id" + } +} +``` + +Purpose: +- queue deletion of a Gmail draft behind explicit approval + +Approval policy: +- required: `true` +- mutation level: `high` + +### `send_gmail_draft` (alias: `send_approved_email`) + +Request: + +```json +{ + "action": "send_gmail_draft", + "args": { + "draft_id": "r-example-draft-id" + } +} +``` + +Purpose: +- queue sending of an existing Gmail draft behind explicit approval + +Approval policy: +- required: `true` +- mutation level: `high` + ### `create_calendar_event` Request: @@ -176,6 +241,10 @@ Purpose: - queue a calendar event proposal for approval - does **not** write to a calendar provider directly in the shipped starter workflow +Approval policy: +- required: `true` +- mutation level: `high` + Sink: - type: `workflow-static-data` - key: `approvalQueue` diff --git a/skills/n8n-webhook/scripts/resolve-approval-with-gog.py b/skills/n8n-webhook/scripts/resolve-approval-with-gog.py index 26fd658..c870c3c 100755 --- a/skills/n8n-webhook/scripts/resolve-approval-with-gog.py +++ b/skills/n8n-webhook/scripts/resolve-approval-with-gog.py @@ -103,7 +103,7 @@ def attach_execution(item_id: str, execution: dict, *, base_url: str, path: str, ) -def build_email_command(item: dict, account: str, dry_run: bool): +def build_email_draft_create_command(item: dict, account: str, dry_run: bool): payload = item.get('payload') or {} body_text = payload.get('body_text') or '' body_html = payload.get('body_html') or '' @@ -137,6 +137,68 @@ def build_email_command(item: dict, account: str, dry_run: bool): return cmd, tmp.name if tmp else None +def build_email_draft_delete_command(item: dict, account: str, dry_run: bool): + payload = item.get('payload') or {} + draft_id = (payload.get('draft_id') or payload.get('id') or '').strip() + if not draft_id: + fail('email_draft_delete payload missing draft_id') + cmd = [ + 'gog', 'gmail', 'drafts', 'delete', draft_id, + '--account', account, + '--json', + '--no-input', + '--force', + ] + if dry_run: + cmd.append('--dry-run') + return cmd + + +def build_email_draft_send_command(item: dict, account: str, dry_run: bool): + payload = item.get('payload') or {} + draft_id = (payload.get('draft_id') or payload.get('id') or '').strip() + if not draft_id: + fail('email_draft_send payload missing draft_id') + cmd = [ + 'gog', 'gmail', 'drafts', 'send', draft_id, + '--account', account, + '--json', + '--no-input', + ] + if dry_run: + cmd.append('--dry-run') + return cmd + + +def build_email_drafts_list_command(item: dict, account: str, dry_run: bool): + payload = item.get('payload') or {} + max_results = payload.get('max') + if max_results is None: + max_results = 20 + try: + max_results = max(1, min(100, int(max_results))) + except Exception: + max_results = 20 + + cmd = [ + 'gog', 'gmail', 'drafts', 'list', + '--account', account, + '--json', + '--no-input', + '--max', str(max_results), + ] + page = (payload.get('page') or '').strip() + if page: + cmd.extend(['--page', page]) + if payload.get('all') is True: + cmd.append('--all') + if payload.get('fail_empty') is True: + cmd.append('--fail-empty') + if dry_run: + cmd.append('--dry-run') + return cmd + + def build_calendar_command(item: dict, account: str, dry_run: bool): payload = item.get('payload') or {} calendar = payload.get('calendar') or 'primary' @@ -211,7 +273,41 @@ def main(): print(json.dumps({'resolved': resolved, 'executed': True, 'driver': 'n8n'}, indent=2)) return - if kind not in {'email_draft', 'calendar_event'}: + executors = { + 'email_draft': { + 'builder': build_email_draft_create_command, + 'op': 'gmail.drafts.create', + 'success_status': 'draft_created', + 'uses_tmpfile': True, + }, + 'email_list_drafts': { + 'builder': build_email_drafts_list_command, + 'op': 'gmail.drafts.list', + 'success_status': 'drafts_listed', + 'uses_tmpfile': False, + }, + 'email_draft_delete': { + 'builder': build_email_draft_delete_command, + 'op': 'gmail.drafts.delete', + 'success_status': 'draft_deleted', + 'uses_tmpfile': False, + }, + 'email_draft_send': { + 'builder': build_email_draft_send_command, + 'op': 'gmail.drafts.send', + 'success_status': 'draft_sent', + 'uses_tmpfile': False, + }, + 'calendar_event': { + 'builder': build_calendar_command, + 'op': 'calendar.create', + 'success_status': 'event_created', + 'uses_tmpfile': False, + }, + } + + spec = executors.get(kind) + if not spec: print(json.dumps({'resolved': resolved, 'executed': False, 'reason': f'no host executor for kind {kind}'}, indent=2)) return @@ -219,15 +315,14 @@ def main(): env = os.environ.copy() env['GOG_ACCOUNT'] = account - if kind == 'email_draft': - cmd, tmpfile = build_email_command(item, account, args.dry_run) - op = 'gmail.drafts.create' - success_status = 'draft_created' if not args.dry_run else 'dry_run' + tmpfile = None + if spec['uses_tmpfile']: + cmd, tmpfile = spec['builder'](item, account, args.dry_run) else: - cmd = build_calendar_command(item, account, args.dry_run) - tmpfile = None - op = 'calendar.create' - success_status = 'event_created' if not args.dry_run else 'dry_run' + cmd = spec['builder'](item, account, args.dry_run) + + op = spec['op'] + success_status = spec['success_status'] if not args.dry_run else 'dry_run' try: code, stdout, stderr = run(cmd, env=env) diff --git a/skills/n8n-webhook/scripts/validate-workflow.py b/skills/n8n-webhook/scripts/validate-workflow.py index 2c18787..cb40e59 100755 --- a/skills/n8n-webhook/scripts/validate-workflow.py +++ b/skills/n8n-webhook/scripts/validate-workflow.py @@ -26,6 +26,10 @@ SAMPLE_FILES = [ 'test-notify.json', 'test-send-notification-draft.json', 'test-send-email-draft.json', + 'test-list-email-drafts.json', + 'test-delete-email-draft.json', + 'test-send-gmail-draft.json', + 'test-send-approved-email.json', 'test-create-calendar-event.json', 'test-fetch-and-normalize-url.json', 'test-approval-queue-list.json', @@ -38,6 +42,10 @@ ROUTER_SNIPPETS = [ 'notify', 'send_notification_draft', 'send_email_draft', + 'list_email_drafts', + 'delete_email_draft', + 'send_gmail_draft', + 'send_approved_email', 'create_calendar_event', 'approval_queue_add', 'approval_queue_list', @@ -50,6 +58,10 @@ ROUTER_SNIPPETS = [ '$getWorkflowStaticData', 'approvalQueue', 'approvalHistory', + 'email_draft_send', + 'email_draft_delete', + 'email_list_drafts', + 'makeApprovalPolicy', 'inboundEvents', 'eventDedup', 'notify_text',