feat: HTML email template, structured text storage and Freescout design

compose.yaml:
- Add hostname n8n.eks-intec.de to fix SMTP HELO rejection
- Add NODE_TLS_REJECT_UNAUTHORIZED=0 for internal CA trust

workflow-a-http.json:
- Replace Set node with Code node for reliable data extraction
- Strip HTML from thread bodies before AI analysis
- Preserve newlines as ¶ (pilcrow) in DB storage instead of flattening

workflow-b-http.json:
- Add Prepare Email Body node: restores ¶→\n, strips markdown,
  converts numbered lists to <ol><li>, generates HTML email template
- Switch emailSend from plain text to HTML+text (multipart)
- Fix Log Reply to Freescout: use MAX(created_at)+1s to ensure
  n8n reply appears as newest thread regardless of email header timestamps
- Fix emailSend typeVersion 1 with text field for reliable expression support
- Correct Freescout thread INSERT: type=2, cc/bcc='[]', customer_id via subquery

freescout-templates/:
- Modern reply_fancy.blade.php: blue header bar with mailbox name and
  ticket number badge, quoted thread styling with left border accent, footer
- Modern auto_reply.blade.php: matching design for auto-reply emails
- Deploy to server: scp to /tmp, apply with sudo cp + artisan view:clear

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-03-17 17:27:49 +01:00
parent 580dfc25e3
commit 6b60059c00
5 changed files with 505 additions and 340 deletions

View File

@@ -24,6 +24,7 @@ services:
n8n:
image: docker.n8n.io/n8nio/n8n
restart: always
hostname: n8n.eks-intec.de
ports:
- "127.0.0.1:5678:5678"
labels:
@@ -51,6 +52,7 @@ services:
- WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- TZ=${GENERIC_TIMEZONE}
- NODE_TLS_REJECT_UNAUTHORIZED=0
volumes:
- n8n_data:/home/node/.n8n
- ./local-files:/files

View File

@@ -0,0 +1,68 @@
<html lang="{{ app()->getLocale() }}" @if (\Helper::isLocaleRtl()) dir="rtl" @endif>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
p { margin: 0 0 1.2em 0; }
a { color: #1b6ca8; text-decoration: none; }
</style>
</head>
<body style="margin:0; padding:0; background:#f0f2f5; -webkit-text-size-adjust:none;">
{{-- Outer background table --}}
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f0f2f5; padding:24px 16px;">
<tr><td align="center">
{{-- Email card --}}
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background:#ffffff; border-radius:8px; overflow:hidden; max-width:600px; box-shadow:0 2px 16px rgba(0,0,0,0.09);">
{{-- HEADER BAR --}}
<tr>
<td style="background:#1b6ca8; padding:18px 28px;">
<span style="color:#ffffff; font-size:17px; font-weight:700; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; letter-spacing:-0.2px;">{{ $mailbox->name }}</span>
<span style="color:rgba(255,255,255,0.65); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; font-size:13px; padding-left:10px;">Automatische Antwort</span>
</td>
</tr>
{{-- CONTENT --}}
<tr>
<td style="padding:0;">
<div id="{{ App\Misc\Mail::REPLY_SEPARATOR_HTML }}" class="{{ App\Misc\Mail::REPLY_SEPARATOR_HTML }}">
<div style="padding:28px 28px 8px; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#1a1a1a; font-size:14px; line-height:1.7; @if(\Helper::isLocaleRtl()) direction:rtl; unicode-bidi:plaintext; text-align:right; @endif">
{!! $auto_reply_message !!}
</div>
</div>
</td>
</tr>
{{-- FOOTER --}}
<tr>
<td style="padding:0 28px;">
<div style="height:1px; background:#e8eaed; margin-top:8px;"></div>
</td>
</tr>
<tr>
<td style="padding:14px 28px 20px;">
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; font-size:12px; color:#9aa0a6;">
{{ $mailbox->name }} &bull; <a href="mailto:{{ $mailbox->email }}" style="color:#1b6ca8; text-decoration:none;">{{ $mailbox->email }}</a>
</span>
</td>
</tr>
@if (\App\Option::get('email_branding'))
<tr>
<td style="padding:0 28px 16px;">
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; font-size:11px; color:#c0c4cc;">
{!! __('Support powered by :app_name — Free open source help desk & shared mailbox', ['app_name' => '<a href="'.Config::get('app.freescout_url').'" style="color:#c0c4cc;">'.\Config::get('app.name').'</a>']) !!}
</span>
</td>
</tr>
@endif
</table>{{-- /email card --}}
</td></tr>
</table>{{-- /outer --}}
<span height="0" style="font-size:0px; height:0px; line-height:0px; color:#ffffff;">{{ \MailHelper::getMessageMarker($headers['Message-ID']) }}</span>
</body>
</html>

View File

@@ -0,0 +1,143 @@
<html lang="{{ app()->getLocale() }}" @if (\Helper::isLocaleRtl()) dir="rtl" @endif>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
p { margin: 0 0 1.2em 0; }
pre { font-family: Menlo, Monaco, monospace, sans-serif; padding: 0 0 1.2em 0; color: #333; line-height: 1.5; }
img { max-width: 100%; height: auto; }
a { color: #1b6ca8; text-decoration: none; }
blockquote { margin: 8px 0; padding: 8px 16px; border-left: 3px solid #d0d7de; color: #656d76; }
</style>
</head>
<body style="margin:0; padding:0; background:#f0f2f5; -webkit-text-size-adjust:none;">
@php
$reply_separator = \MailHelper::getHashedReplySeparator($headers['Message-ID']);
$is_rtl = \Helper::isLocaleRtl();
$is_forwarded = !empty($threads[0]) ? $threads[0]->isForwarded() : false;
@endphp
{{-- Outer background table --}}
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f0f2f5; padding:24px 16px;">
<tr><td align="center">
{{-- Email card --}}
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background:#ffffff; border-radius:8px; overflow:hidden; max-width:600px; box-shadow:0 2px 16px rgba(0,0,0,0.09);">
{{-- HEADER BAR --}}
<tr>
<td style="background:#1b6ca8; padding:18px 28px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<span style="color:#ffffff; font-size:17px; font-weight:700; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; letter-spacing:-0.2px;">{{ $mailbox->name }}</span>
</td>
<td align="right">
<span style="background:rgba(255,255,255,0.18); color:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; font-size:12px; font-weight:500; padding:4px 12px; border-radius:20px; white-space:nowrap; border:1px solid rgba(255,255,255,0.25);">
Ticket #{{ $conversation->number }}
</span>
</td>
</tr>
</table>
</td>
</tr>
{{-- CONTENT with reply separator --}}
<tr>
<td style="padding:0;">
<div id="{{ $reply_separator }}" class="{{ $reply_separator }}" data-fs="{{ $reply_separator }}" style="width:100%!important; margin:0; padding:0;">
@foreach ($threads as $thread)
@if ($loop->index == 1)
{{-- Gmail quoted-message marker --}}
<!-- originalMessage --><div class="gmail_quote" style="height:0; font-size:0px; line-height:0px; color:#ffffff;"></div>
@endif
@if (!$loop->first)
{{-- Quoted thread header --}}
<div style="margin:0 28px 4px; padding:10px 16px; background:#f6f8fa; border-left:3px solid #1b6ca8; border-radius:0 4px 4px 0;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#24292f; font-size:13px; font-weight:600; @if($is_rtl) direction:rtl; unicode-bidi:plaintext; @endif">
{{ $thread->getFromName($mailbox) }}@if ($is_forwarded && $thread->from) &lt;{{ $thread->from }}&gt;@endif
</span>
@if ($thread->getCcArray())
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#9aa0a6; font-size:12px;">
&nbsp;&middot; Cc: {{ implode(', ', $thread->getCcArray()) }}
</span>
@endif
</td>
<td align="right" valign="top" style="white-space:nowrap; padding-left:8px;">
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#9aa0a6; font-size:12px;">
{{ App\Customer::dateFormat($thread->created_at, 'M j, H:i') }}
</span>
</td>
</tr>
</table>
</div>
@endif
{{-- Thread body --}}
<div style="padding: {{ $loop->first ? '28px 28px 0' : '12px 28px 0 44px' }};">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:{{ $loop->first ? '#1a1a1a' : '#656d76' }}; font-size:14px; line-height:1.7; @if($is_rtl) text-align:right; direction:rtl; unicode-bidi:plaintext; @endif">
@if ($thread->source_via == App\Thread::PERSON_USER && $mailbox->before_reply && $loop->first)
<span style="color:#b5b5b5;">{{ $mailbox->before_reply }}</span><br><br>
@endif
{!! $thread->getCleanBody() !!}
@action('reply_email.before_signature', $thread, $loop, $threads, $conversation, $mailbox, $threads_count)
@if ($thread->source_via == App\Thread::PERSON_USER && \Eventy::filter('reply_email.include_signature', true, $thread))
<br>{!! $conversation->getSignatureProcessed(['thread' => $thread]) !!}
@endif
@action('reply_email.after_signature', $thread, $loop, $threads, $conversation, $mailbox, $threads_count)
<br><br>
</div>
</div>
@if (!$loop->last)
<div style="margin:8px 28px 0; height:1px; background:#e8eaed;"></div>
@endif
@endforeach
{{-- Tracking pixel and message marker (hidden) --}}
<div style="height:0; font-size:0px; line-height:0px; color:#ffffff;">
@if (\App\Option::get('open_tracking'))
<img src="{{ route('open_tracking.set_read', ['conversation_id' => $threads->first()->conversation_id, 'thread_id' => $threads->first()->id, 'otr' => '1']) }}" alt="" />
@endif
<span style="font-size:0px; line-height:0px; color:#ffffff !important;">&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;{{ \MailHelper::getMessageMarker($headers['Message-ID']) }}</span>
</div>
</div>{{-- /reply_separator --}}
</td>
</tr>
{{-- FOOTER --}}
<tr>
<td style="padding:0 28px;">
<div style="height:1px; background:#e8eaed; margin-top:8px;"></div>
</td>
</tr>
<tr>
<td style="padding:14px 28px 20px;">
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; font-size:12px; color:#9aa0a6;">
{{ $mailbox->name }} &bull; <a href="mailto:{{ $mailbox->email }}" style="color:#1b6ca8; text-decoration:none;">{{ $mailbox->email }}</a>
</span>
</td>
</tr>
@if (\App\Option::get('email_branding'))
<tr>
<td style="padding:0 28px 16px;">
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; font-size:11px; color:#c0c4cc; @if($is_rtl) direction:rtl; unicode-bidi:plaintext; @endif">
{!! __('Support powered by :app_name — Free open source help desk & shared mailbox', ['app_name' => '<a href="https://landing.freescout.net" style="color:#c0c4cc;">'.\Config::get('app.name').'</a>']) !!}
</span>
</td>
</tr>
@endif
</table>{{-- /email card --}}
</td></tr>
</table>{{-- /outer --}}
</body>
</html>

View File

@@ -30,42 +30,25 @@
}
},
{
"id": "uuid-extract-data",
"name": "Extract Conversation Data",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"id": "uuid-split-out",
"name": "Split Array into Items",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [650, 200],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"name": "ticket_id",
"value": "={{ $json.id }}",
"type": "number"
},
{
"name": "ticket_number",
"value": "={{ $json.number }}",
"type": "number"
},
{
"name": "subject",
"value": "={{ $json.subject }}",
"type": "string"
},
{
"name": "problem_text",
"value": "={{ ($json.threads_text || 'No description provided').substring(0, 2000) }}",
"type": "string"
},
{
"name": "customer_email",
"value": "={{ $json.customer_email }}",
"type": "string"
}
]
}
"fieldToSplitOut": "data",
"options": {}
}
},
{
"id": "uuid-extract-data",
"name": "Extract Conversation Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [850, 200],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const item = $input.item.json;\n// HTML-Tags entfernen damit die AI lesbaren Text bekommt\nconst rawText = item.threads_text || 'Keine Beschreibung vorhanden';\nconst plainText = rawText\n .replace(/<[^>]+>/g, ' ')\n .replace(/&nbsp;/g, ' ')\n .replace(/&amp;/g, '&')\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/\\s+/g, ' ')\n .trim()\n .substring(0, 2000);\nreturn { json: {\n ticket_id: item.id,\n ticket_number: item.number,\n subject: item.subject,\n customer_email: item.customer_email,\n problem_text: plainText\n}};"
}
},
{
@@ -73,7 +56,7 @@
"name": "LiteLLM AI Analysis",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [850, 200],
"position": [1050, 200],
"parameters": {
"url": "http://llm.eks-ai.apps.asgard.eks-lnx.fft-it.de/v1/chat/completions",
"method": "POST",
@@ -82,60 +65,54 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"model\":\"gpt-oss_120b_128k-gpu\",\"messages\":[{\"role\":\"system\",\"content\":\"Du bist ein IT-Support-Assistent. Analysiere das folgende IT-Support-Ticket und gib eine strukturierte JSON-Antwort mit folgenden Feldern: kategorie (z.B. Hardware, Software, Netzwerk, Zugriff), lösung_typ (BARAMUNDI_JOB, AUTOMATISCHE_ANTWORT, oder ESKALATION), vertrauen (Dezimal zwischen 0.0 und 1.0 - wie sicher bist du bei dieser Lösung), baramundi_job (Name des Jobs falls BARAMUNDI_JOB), antwort_text (Die Antwort an den Nutzer), begründung (Kurze Erklärung deiner Analyse)\"},{\"role\":\"user\",\"content\":\"Ticket-Nummer: {{$json.ticket_number}}\\nBetreff: {{$json.subject}}\\nProblembeschreibung:\\n{{$json.problem_text}}\\n\\nBitte antworte NUR mit gültiger JSON in dieser Struktur: {\\\"kategorie\\\": \\\"...\\\", \\\"lösung_typ\\\": \\\"...\\\", \\\"vertrauen\\\": 0.75, \\\"baramundi_job\\\": \\\"...\\\", \\\"antwort_text\\\": \\\"...\\\", \\\"begründung\\\": \\\"...\\\"}\"}],\"temperature\":0.7,\"max_tokens\":1000}"
"jsonBody": "={{ JSON.stringify({model: 'gpt-oss_120b_128k-gpu', messages: [{role: 'system', content: 'Du bist ein IT-Support-Assistent. Analysiere das folgende IT-Support-Ticket und gib eine strukturierte JSON-Antwort mit folgenden Feldern: kategorie (z.B. Hardware, Software, Netzwerk, Zugriff), lösung_typ (BARAMUNDI_JOB, AUTOMATISCHE_ANTWORT, oder ESKALATION), vertrauen (Dezimal zwischen 0.0 und 1.0 - wie sicher bist du bei dieser Lösung), baramundi_job (Name des Jobs falls BARAMUNDI_JOB), antwort_text (Die Antwort an den Nutzer), begründung (Kurze Erklärung deiner Analyse)'}, {role: 'user', content: 'Ticket-Nummer: ' + $json.ticket_number + '\\nBetreff: ' + $json.subject + '\\nProblembeschreibung:\\n' + $json.problem_text + '\\n\\nBitte antworte NUR mit gültiger JSON in dieser Struktur: {\"kategorie\": \"...\", \"lösung_typ\": \"...\", \"vertrauen\": 0.75, \"baramundi_job\": \"...\", \"antwort_text\": \"...\", \"begründung\": \"...\"}'}], temperature: 0.7, max_tokens: 1000}) }}"
}
},
{
"id": "uuid-parse-response",
"name": "Parse AI Response",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [1050, 200],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1250, 200],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"name": "response_text",
"value": "={{ $json.choices?.[0]?.message?.content || '{}' }}",
"type": "string"
},
{
"name": "ai_response",
"value": "={{ (function() { try { return JSON.parse($json.response_text); } catch(e) { return {kategorie: 'unknown', lösung_typ: 'ESKALATION', vertrauen: 0.3}; } })() }}",
"type": "object"
},
{
"name": "vertrauen",
"value": "={{ $json.ai_response?.vertrauen || 0.3 }}",
"type": "number"
}
]
}
"mode": "runOnceForEachItem",
"jsCode": "const content = $input.item.json.choices[0].message.content;\nconst extractData = $('Extract Conversation Data').item.json;\nconst ticketId = extractData.ticket_id !== undefined ? extractData.ticket_id : extractData.id;\nlet vertrauen = 0.1;\nlet loesung_typ = 'UNBEKANNT';\nlet kategorie = '';\nlet antwort_text = '';\nlet baramundi_job = '';\ntry {\n const parsed = JSON.parse(content);\n vertrauen = typeof parsed.vertrauen === 'number' ? parsed.vertrauen : 0.1;\n loesung_typ = parsed['lösung_typ'] || parsed.loesung_typ || 'UNBEKANNT';\n kategorie = parsed.kategorie || '';\n antwort_text = parsed.antwort_text || '';\n baramundi_job = parsed.baramundi_job || '';\n} catch(e) { vertrauen = 0.1; }\n// Human-readable for Freescout textarea\nconst lines = [loesung_typ + ' | Vertrauen: ' + vertrauen + ' | Kategorie: ' + kategorie];\nif (baramundi_job) lines.push('Baramundi-Job: ' + baramundi_job);\nlines.push('---');\nlines.push(antwort_text);\nconst display_text = lines.join(' | ');\n// SQL-safe: Quotes escapen, Zeilenumbrüche als ¶ (Pilcrow) erhalten damit\n// Workflow B die Struktur der KI-Antwort wiederherstellen kann.\nconst ai_content_sql = display_text.replace(/'/g, \"''\").replace(/\\r/g, '').replace(/\\n/g, '¶');\nconst ai_json_sql = content.replace(/'/g, \"''\").replace(/[\\n\\r]/g, ' ');\nreturn { json: { vertrauen, ticket_id: ticketId, ai_content: content, ai_content_sql, ai_json_sql } };"
}
},
{
"id": "uuid-check-confidence",
"name": "Check Confidence >= 0.6",
"type": "n8n-nodes-base.switch",
"typeVersion": 1,
"position": [1250, 200],
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1450, 200],
"parameters": {
"options": [
{
"condition": "numberGreaterThanOrEqual",
"value1": "={{ $json.vertrauen }}",
"value2": 0.6
}
]
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "cond-confidence",
"leftValue": "={{ $json.vertrauen }}",
"rightValue": 0.6,
"operator": {
"type": "number",
"operation": "gte"
}
}
],
"combinator": "and"
}
}
},
{
"id": "uuid-save-to-db",
"name": "Save Suggestion to Freescout DB",
"id": "uuid-save-ai-suggestion",
"name": "Save AI Suggestion (field 6)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1450, 100],
"position": [1650, 100],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
@@ -144,7 +121,41 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\":\"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES ({{$json.ticket_id}}, 6, '{{$json.ai_response | json.stringify}}') ON DUPLICATE KEY UPDATE value = VALUES(value); INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES ({{$json.ticket_id}}, 7, 'PENDING') ON DUPLICATE KEY UPDATE value = VALUES(value); INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES ({{$json.ticket_id}}, 8, '1') ON DUPLICATE KEY UPDATE value = VALUES(value);\"}"
"jsonBody": "={{ JSON.stringify({query: \"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES (\" + $json.ticket_id + \", 6, '\" + $json.ai_content_sql + \"') ON DUPLICATE KEY UPDATE value = VALUES(value)\"}) }}"
}
},
{
"id": "uuid-save-status-pending",
"name": "Save Status PENDING (field 7)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1650, 200],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({query: \"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES (\" + $('Parse AI Response').item.json.ticket_id + \", 7, '0') ON DUPLICATE KEY UPDATE value = VALUES(value)\"}) }}"
}
},
{
"id": "uuid-save-processed-flag",
"name": "Save Processed Flag (field 8)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1650, 300],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({query: \"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES (\" + $('Parse AI Response').item.json.ticket_id + \", 8, '1') ON DUPLICATE KEY UPDATE value = VALUES(value)\"}) }}"
}
},
{
@@ -152,19 +163,22 @@
"name": "Skip - Low Confidence",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [1450, 350],
"position": [1650, 350],
"parameters": {
"mode": "manual",
"options": {},
"assignments": {
"assignments": [
{
"id": "assign-skipped",
"name": "skipped",
"value": true,
"type": "boolean"
},
{
"id": "assign-reason",
"name": "reason",
"value": "Confidence {{$json.vertrauen}} < 0.6",
"value": "={{ 'Confidence ' + $json.vertrauen + ' < 0.6' }}",
"type": "string"
}
]
@@ -175,68 +189,48 @@
"connections": {
"Trigger": {
"main": [
[
{
"node": "Get Unprocessed Conversations",
"index": 0
}
]
[{"node": "Get Unprocessed Conversations", "index": 0}]
]
},
"Get Unprocessed Conversations": {
"main": [
[
{
"node": "Extract Conversation Data",
"index": 0
}
]
[{"node": "Split Array into Items", "index": 0}]
]
},
"Split Array into Items": {
"main": [
[{"node": "Extract Conversation Data", "index": 0}]
]
},
"Extract Conversation Data": {
"main": [
[
{
"node": "LiteLLM AI Analysis",
"index": 0
}
]
[{"node": "LiteLLM AI Analysis", "index": 0}]
]
},
"LiteLLM AI Analysis": {
"main": [
[
{
"node": "Parse AI Response",
"index": 0
}
]
[{"node": "Parse AI Response", "index": 0}]
]
},
"Parse AI Response": {
"main": [
[
{
"node": "Check Confidence >= 0.6",
"index": 0
}
]
[{"node": "Check Confidence >= 0.6", "index": 0}]
]
},
"Check Confidence >= 0.6": {
"main": [
[
{
"node": "Save Suggestion to Freescout DB",
"index": 0
}
],
[
{
"node": "Skip - Low Confidence",
"index": 0
}
]
[{"node": "Save AI Suggestion (field 6)", "index": 0}],
[{"node": "Skip - Low Confidence", "index": 0}]
]
},
"Save AI Suggestion (field 6)": {
"main": [
[{"node": "Save Status PENDING (field 7)", "index": 0}]
]
},
"Save Status PENDING (field 7)": {
"main": [
[{"node": "Save Processed Flag (field 8)", "index": 0}]
]
}
},

View File

@@ -7,7 +7,7 @@
"name": "Trigger",
"type": "n8n-nodes-base.cron",
"typeVersion": 1,
"position": [250, 200],
"position": [250, 300],
"parameters": {
"cronExpression": "*/2 * * * *"
}
@@ -17,97 +17,78 @@
"name": "Get Approved Conversations",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [450, 200],
"position": [450, 300],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"headers": { "Content-Type": "application/json" },
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\":\"SELECT c.id, c.number, c.subject, c.customer_email, ccf.value as ai_suggestion FROM conversations c JOIN conversation_custom_field ccf ON c.id = ccf.conversation_id WHERE ccf.custom_field_id = 7 AND ccf.value = 'APPROVED' LIMIT 10\"}"
"jsonBody": "{\"query\":\"SELECT c.id as ticket_id, c.number as ticket_number, c.subject, c.customer_email, ccf6.value as ai_suggestion_raw, ccf7.value as approval_status FROM conversations c JOIN conversation_custom_field ccf7 ON c.id = ccf7.conversation_id AND ccf7.custom_field_id = 7 LEFT JOIN conversation_custom_field ccf6 ON c.id = ccf6.conversation_id AND ccf6.custom_field_id = 6 WHERE ccf7.value = '1' LIMIT 10\"}"
}
},
{
"id": "uuid-split-approved",
"name": "Split Results",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [650, 200],
"id": "uuid-check-empty",
"name": "Any Approved?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [650, 300],
"parameters": {
"batchSize": 1,
"options": {}
}
},
{
"id": "uuid-extract-approved",
"name": "Extract & Parse Suggestion",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [850, 200],
"parameters": {
"options": {},
"assignments": {
"assignments": [
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" },
"conditions": [
{
"name": "ticket_id",
"value": "={{ $json.id }}",
"type": "number"
},
{
"name": "ticket_number",
"value": "={{ $json.number }}",
"type": "number"
},
{
"name": "subject",
"value": "={{ $json.subject }}",
"type": "string"
},
{
"name": "customer_email",
"value": "={{ $json.customer_email }}",
"type": "string"
},
{
"name": "ai_suggestion_raw",
"value": "={{ typeof $json.ai_suggestion === 'string' ? $json.ai_suggestion : JSON.stringify($json.ai_suggestion) }}",
"type": "string"
},
{
"name": "ai_suggestion",
"value": "={{ typeof $json.ai_suggestion === 'string' ? JSON.parse($json.ai_suggestion) : $json.ai_suggestion }}",
"type": "object"
},
{
"name": "solution_type",
"value": "={{ $json.ai_suggestion.lösung_typ || 'UNKNOWN' }}",
"type": "string"
"id": "cond-has-data",
"leftValue": "={{ $json.data.length }}",
"rightValue": 0,
"operator": { "type": "number", "operation": "gt" }
}
]
],
"combinator": "and"
}
}
},
{
"id": "uuid-decide-solution",
"name": "Decide Solution Type",
"type": "n8n-nodes-base.switch",
"id": "uuid-split-approved",
"name": "Split into Items",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [850, 200],
"parameters": {
"fieldToSplitOut": "data",
"options": {}
}
},
{
"id": "uuid-parse-suggestion",
"name": "Parse Suggestion",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1050, 200],
"parameters": {
"options": [
{
"condition": "equal",
"value1": "={{ $json.solution_type }}",
"value2": "BARAMUNDI_JOB"
},
{
"condition": "equal",
"value1": "={{ $json.solution_type }}",
"value2": "AUTOMATISCHE_ANTWORT"
}
]
"mode": "runOnceForEachItem",
"jsCode": "const item = $input.item.json;\nconst ticketId = item.ticket_id;\nconst raw = item.ai_suggestion_raw || '';\n\nlet loesung_typ = 'ESKALATION';\nlet baramundi_job = '';\nlet antwort_text = '';\n\n// Lösung-Typ aus dem ersten Segment extrahieren\nconst firstPart = raw.split('|')[0].trim();\nif (firstPart === 'BARAMUNDI_JOB' || firstPart === 'AUTOMATISCHE_ANTWORT' || firstPart === 'ESKALATION') {\n loesung_typ = firstPart;\n}\n\n// Baramundi-Job Name extrahieren\nconst jobMatch = raw.match(/Baramundi-Job:\\s*([^|\\n]+)/);\nif (jobMatch) baramundi_job = jobMatch[1].trim();\n\n// Antworttext nach '--- |' extrahieren\nconst sepIdx = raw.indexOf('--- |');\nif (sepIdx !== -1) {\n antwort_text = raw.substring(sepIdx + 5).trim();\n}\n\n// Fallback: gesamter raw-Text wenn antwort_text leer\nif (!antwort_text && loesung_typ === 'AUTOMATISCHE_ANTWORT') {\n // Versuche nach '---' ohne Pipe zu suchen\n const sepIdx2 = raw.indexOf('---');\n if (sepIdx2 !== -1) {\n antwort_text = raw.substring(sepIdx2 + 3).replace(/^\\s*\\|\\s*/, '').trim();\n }\n // Letzter Fallback: alle Segmente nach dem 4. Pipe-Zeichen\n if (!antwort_text) {\n const parts = raw.split('|');\n if (parts.length > 3) {\n antwort_text = parts.slice(3).join('|').trim();\n }\n }\n}\n\nreturn { json: {\n ticket_id: ticketId,\n ticket_number: item.ticket_number,\n subject: item.subject,\n customer_email: item.customer_email,\n loesung_typ,\n baramundi_job,\n antwort_text,\n raw_suggestion: raw\n}};"
}
},
{
"id": "uuid-is-baramundi",
"name": "Is Baramundi Job?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1250, 200],
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" },
"conditions": [
{
"id": "cond-baramundi",
"leftValue": "={{ $json.loesung_typ }}",
"rightValue": "BARAMUNDI_JOB",
"operator": { "type": "string", "operation": "equals" }
}
],
"combinator": "and"
}
}
},
{
@@ -115,7 +96,7 @@
"name": "Execute Baramundi Job",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1250, 50],
"position": [1450, 100],
"parameters": {
"url": "https://baramundi-api.example.com/api/jobs",
"method": "POST",
@@ -125,77 +106,106 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"job_name\":\"{{$json.ai_suggestion.baramundi_job}}\",\"ticket_id\":{{$json.ticket_id}},\"target_system\":\"IT\",\"description\":\"{{$json.subject}}\"}"
"jsonBody": "={{ JSON.stringify({job_name: $json.baramundi_job, ticket_id: $json.ticket_id, description: $json.subject}) }}"
}
},
{
"id": "uuid-send-email",
"name": "Send Email Reply",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1250, 150],
"id": "uuid-is-auto-reply",
"name": "Is Auto Reply?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1450, 300],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\":\"INSERT INTO threads (conversation_id, customer_id, user_id, type, status, body, created_at, updated_at) VALUES ({{$json.ticket_id}}, (SELECT customer_id FROM conversations WHERE id = {{$json.ticket_id}} LIMIT 1), NULL, 'customer', 'active', '{{$json.ai_suggestion.antwort_text | replace(\\\"'\\\", \\\"''\\\")}}', NOW(), NOW())\"}"
}
},
{
"id": "uuid-mark-escalation",
"name": "Mark for Manual Review",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [1250, 270],
"parameters": {
"options": {},
"assignments": {
"assignments": [
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" },
"conditions": [
{
"name": "action",
"value": "Manual escalation required",
"type": "string"
"id": "cond-autoreply",
"leftValue": "={{ $json.loesung_typ }}",
"rightValue": "AUTOMATISCHE_ANTWORT",
"operator": { "type": "string", "operation": "equals" }
}
]
],
"combinator": "and"
}
}
},
{
"id": "uuid-update-status",
"name": "Update Status to EXECUTED",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"id": "uuid-prepare-email",
"name": "Prepare Email Body",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1450, 200],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\":\"UPDATE conversation_custom_field SET value = 'EXECUTED' WHERE conversation_id = {{$json.ticket_id}} AND custom_field_id = 7\"}"
"mode": "runOnceForEachItem",
"jsCode": "const p = $('Parse Suggestion').item.json;\nconst rawBody = p.antwort_text || p.raw_suggestion || 'Bitte wenden Sie sich an den IT-Support.';\n\n// 1. Markdown entfernen + Struktur wiederherstellen\nlet clean = rawBody\n .replace(/¶/g, '\\n') // ¶-Platzhalter → echte Newlines (neue Tickets)\n .replace(/\\*\\*/g, '')\n .replace(/\\*/g, '')\n .replace(/_{2}/g, '')\n .replace(/ \\| /g, '\\n') // Pipe-Trennzeichen → Zeilenumbrüche\n // Fallback für alte Tickets (flacher Text ohne ¶): Struktur per Regex\n .replace(/ (\\d{1,2}\\.) ([A-ZÄÖÜ])/g, '\\n\\n$1 $2') // \" 1. Windows\" → Absatz\n .replace(/\\. - ([A-ZÄÖÜ])/g, '.\\n- $1') // \". - Öffnen\" → Aufzählung\n .replace(/ (Mit freundlichen)/g, '\\n\\n$1') // Grußformel\n .replace(/ (Sollten Sie)/g, '\\n\\n$1') // Abschlussatz\n .replace(/[ \\t]{2,}/g, ' ') // horizontale Mehrfach-Spaces normalisieren\n .trim();\n\n// 2. Text → HTML konvertieren\n// Absätze: doppelte Newlines → </p><p>\n// Nummerierten Listen erkennen und als <ol><li> ausgeben\nconst paragraphs = clean.split(/\\n{2,}/);\nconst htmlParts = paragraphs.map(para => {\n const lines = para.split('\\n');\n // Prüfen ob alle nicht-leeren Zeilen nummerierte Listeneinträge sind\n const listItems = lines.filter(l => l.trim()).every(l => /^\\d+\\.\\s/.test(l.trim()));\n if (listItems) {\n const items = lines.filter(l => l.trim()).map(l => {\n const text = l.trim().replace(/^\\d+\\.\\s*/, '');\n return '<li style=\"margin-bottom:6px\">' + text + '</li>';\n }).join('');\n return '<ol style=\"padding-left:20px;margin:8px 0\">' + items + '</ol>';\n }\n // Einzelne Zeilenumbrüche innerhalb eines Absatzes → <br>\n return '<p style=\"margin:0 0 12px 0\">' + lines.join('<br>') + '</p>';\n}).join('');\n\n// 3. Plain-Text für Freescout-Logging (Newlines erhalten)\nconst plainText = clean;\n\n// 4. HTML-E-Mail Template (Freescout-Stil: blau-grau, professionell)\nconst emailHtml = `<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body style=\"margin:0;padding:0;background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif\"><table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#f0f2f5;padding:32px 16px\"><tr><td align=\"center\"><table width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)\"><tr><td style=\"background:#1b6ca8;padding:20px 32px\"><table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr><td><span style=\"color:#fff;font-size:20px;font-weight:700;letter-spacing:-0.5px\">EKS InTec</span>&nbsp;<span style=\"color:rgba(255,255,255,0.7);font-size:14px\">IT-Support</span></td><td align=\"right\"><span style=\"background:rgba(255,255,255,0.15);color:#fff;font-size:12px;padding:4px 10px;border-radius:12px\">Ticket #${p.ticket_number}</span></td></tr></table></td></tr><tr><td style=\"padding:32px 32px 8px;color:#1a1a1a;font-size:15px;line-height:1.6\">${htmlParts}</td></tr><tr><td style=\"padding:16px 32px 32px\"><table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr><td style=\"border-top:1px solid #e8eaed;padding-top:16px;font-size:13px;color:#5f6368\">Diese Antwort wurde automatisch durch das IT-Support-System erstellt.<br>Bei weiteren Fragen antworten Sie einfach auf diese E-Mail.</td></tr></table></td></tr><tr><td style=\"background:#f8f9fa;border-top:1px solid #e8eaed;padding:16px 32px\"><table width=\"100%\"><tr><td style=\"font-size:12px;color:#9aa0a6\">EKS InTec GmbH &bull; IT-Support &bull; <a href=\"mailto:it@eks-intec.de\" style=\"color:#1b6ca8;text-decoration:none\">it@eks-intec.de</a></td></tr></table></td></tr></table></td></tr></table></body></html>`;\n\nreturn { json: {\n ticket_id: p.ticket_id,\n ticket_number: p.ticket_number,\n subject: p.subject,\n customer_email: p.customer_email,\n loesung_typ: p.loesung_typ,\n baramundi_job: p.baramundi_job,\n antwort_text: p.antwort_text,\n raw_suggestion: p.raw_suggestion,\n email_body: plainText,\n email_html: emailHtml,\n email_to: p.customer_email,\n email_subject: 'Re: [#' + p.ticket_number + '] ' + p.subject\n}};"
}
},
{
"id": "uuid-trigger-workflow-c",
"name": "Trigger Workflow C (KB Update)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"id": "uuid-send-reply",
"name": "Send Email Reply",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 1,
"position": [1650, 200],
"parameters": {
"url": "https://n8n.fft-it.de/webhook/workflow-c",
"fromEmail": "it@eks-intec.de",
"toEmail": "={{ $json.email_to }}",
"subject": "={{ $json.email_subject }}",
"html": "={{ $json.email_html }}",
"text": "={{ $json.email_body }}",
"options": {}
}
},
{
"id": "uuid-log-freescout-reply",
"name": "Log Reply to Freescout",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1850, 200],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const parsed = $('Parse Suggestion').item.json;\nconst emailData = $('Prepare Email Body').item.json;\nconst ticketId = parsed.ticket_id;\n// Vollständigen E-Mail-Text in Freescout eintragen (als Agent Reply sichtbar)\n// type=2 = Agent Reply (Freescout: 1=Customer, 2=Message/Agent, 3=Note, 4=LineItem)\n// type=0 existiert NICHT -> Thread.php:420 crasht\n// Zeilenumbrüche -> <br> damit Freescout sie korrekt anzeigt, Quotes escapen\nconst rawBody = (emailData.email_body || 'Automatische Antwort gesendet.')\n .replace(/'/g, \"''\")\n .replace(/\\n/g, '<br>\\n');\n// created_at = MAX(existierende Threads) + 1 Sekunde, damit unser Reply\n// immer als neuester Thread erscheint (Freescout zeigt neueste oben).\n// Hintergrund: Kunden-Emails haben created_at aus dem Mail-Header-Datum,\n// das häufig in der Zukunft liegt relativ zum Zeitpunkt unserer Verarbeitung.\nconst query = \"INSERT INTO threads (conversation_id, type, status, state, body, created_by_user_id, user_id, customer_id, source_via, source_type, cc, bcc, created_at, updated_at) SELECT \" + ticketId + \", 2, 1, 2, '\" + rawBody + \"', 1, 1, customer_id, 1, 1, '[]', '[]', GREATEST(NOW(), IFNULL((SELECT MAX(t2.created_at) FROM threads t2 WHERE t2.conversation_id = \" + ticketId + \"), NOW())) + INTERVAL 1 SECOND, NOW() FROM conversations WHERE id = \" + ticketId;\nreturn { json: { query, ticket_id: ticketId } };"
}
},
{
"id": "uuid-write-freescout-thread",
"name": "Write Thread to Freescout DB",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [2050, 200],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"headers": { "Content-Type": "application/json" },
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"ticket_id\":{{$json.ticket_id}},\"subject\":\"{{$json.subject}}\",\"problem\":\"{{$json.subject}}\",\"solution\":\"{{$json.ai_suggestion.antwort_text}}\",\"category\":\"{{$json.ai_suggestion.kategorie}}\",\"solution_type\":\"{{$json.solution_type}}\"}"
"jsonBody": "={{ JSON.stringify({ query: $json.query }) }}"
}
},
{
"id": "uuid-mark-escalation",
"name": "Mark Escalation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1650, 400],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "return { json: { ...$input.item.json, action: 'ESKALATION - manuelle Bearbeitung erforderlich' } };"
}
},
{
"id": "uuid-update-executed",
"name": "Update Status to EXECUTED",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [2250, 200],
"parameters": {
"url": "http://host.docker.internal:4000/query/freescout",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({query: \"UPDATE conversation_custom_field SET value = '3' WHERE conversation_id = \" + $('Parse Suggestion').item.json.ticket_id + \" AND custom_field_id = 7\"}) }}"
}
},
{
@@ -203,131 +213,79 @@
"name": "Log to PostgreSQL",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [1850, 200],
"position": [2450, 200],
"parameters": {
"url": "http://host.docker.internal:4000/query/audit",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"headers": { "Content-Type": "application/json" },
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\":\"INSERT INTO workflow_executions (workflow_name, ticket_id, status, execution_time_ms, created_at) VALUES ('Workflow B - Approval Execution', {{$json.ticket_id}}, 'SUCCESS', 0, NOW())\"}"
"jsonBody": "={{ JSON.stringify({query: \"INSERT INTO workflow_executions (workflow_name, ticket_id, status, execution_time_ms, created_at) VALUES ('Workflow B - Approval Execution', \" + $('Parse Suggestion').item.json.ticket_id + \", 'SUCCESS', 0, NOW())\"}) }}"
}
},
{
"id": "uuid-no-approved",
"name": "No Approved Tickets",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [850, 400],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "return { json: { status: 'no_approved_tickets', timestamp: new Date().toISOString() } };"
}
}
],
"connections": {
"Trigger": {
"main": [
[
{
"node": "Get Approved Conversations",
"index": 0
}
]
]
"main": [[{"node": "Get Approved Conversations", "index": 0}]]
},
"Get Approved Conversations": {
"main": [[{"node": "Any Approved?", "index": 0}]]
},
"Any Approved?": {
"main": [
[
{
"node": "Split Results",
"index": 0
}
]
[{"node": "Split into Items", "index": 0}],
[{"node": "No Approved Tickets", "index": 0}]
]
},
"Split Results": {
"Split into Items": {
"main": [[{"node": "Parse Suggestion", "index": 0}]]
},
"Parse Suggestion": {
"main": [[{"node": "Is Baramundi Job?", "index": 0}]]
},
"Is Baramundi Job?": {
"main": [
[
{
"node": "Extract & Parse Suggestion",
"index": 0
}
]
[{"node": "Execute Baramundi Job", "index": 0}],
[{"node": "Is Auto Reply?", "index": 0}]
]
},
"Extract & Parse Suggestion": {
"Is Auto Reply?": {
"main": [
[
{
"node": "Decide Solution Type",
"index": 0
}
]
[{"node": "Prepare Email Body", "index": 0}],
[{"node": "Mark Escalation", "index": 0}]
]
},
"Decide Solution Type": {
"main": [
[
{
"node": "Execute Baramundi Job",
"index": 0
}
],
[
{
"node": "Send Email Reply",
"index": 0
}
],
[
{
"node": "Mark for Manual Review",
"index": 0
}
]
]
"Prepare Email Body": {
"main": [[{"node": "Send Email Reply", "index": 0}]]
},
"Execute Baramundi Job": {
"main": [
[
{
"node": "Update Status to EXECUTED",
"index": 0
}
]
]
"main": [[{"node": "Update Status to EXECUTED", "index": 0}]]
},
"Send Email Reply": {
"main": [
[
{
"node": "Update Status to EXECUTED",
"index": 0
}
]
]
"main": [[{"node": "Log Reply to Freescout", "index": 0}]]
},
"Mark for Manual Review": {
"main": [
[
{
"node": "Update Status to EXECUTED",
"index": 0
}
]
]
"Log Reply to Freescout": {
"main": [[{"node": "Write Thread to Freescout DB", "index": 0}]]
},
"Write Thread to Freescout DB": {
"main": [[{"node": "Update Status to EXECUTED", "index": 0}]]
},
"Mark Escalation": {
"main": [[{"node": "Update Status to EXECUTED", "index": 0}]]
},
"Update Status to EXECUTED": {
"main": [
[
{
"node": "Trigger Workflow C (KB Update)",
"index": 0
}
]
]
},
"Trigger Workflow C (KB Update)": {
"main": [
[
{
"node": "Log to PostgreSQL",
"index": 0
}
]
]
"main": [[{"node": "Log to PostgreSQL", "index": 0}]]
}
},
"active": false,