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>
296 lines
16 KiB
JSON
296 lines
16 KiB
JSON
{
|
|
"name": "Workflow B - Approval & Execution (HTTP)",
|
|
"description": "Poll for approved AI suggestions and execute them (Baramundi jobs or email replies)",
|
|
"nodes": [
|
|
{
|
|
"id": "uuid-trigger-b",
|
|
"name": "Trigger",
|
|
"type": "n8n-nodes-base.cron",
|
|
"typeVersion": 1,
|
|
"position": [250, 300],
|
|
"parameters": {
|
|
"cronExpression": "*/2 * * * *"
|
|
}
|
|
},
|
|
{
|
|
"id": "uuid-get-approved",
|
|
"name": "Get Approved Conversations",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4,
|
|
"position": [450, 300],
|
|
"parameters": {
|
|
"url": "http://host.docker.internal:4000/query/freescout",
|
|
"method": "POST",
|
|
"headers": { "Content-Type": "application/json" },
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"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-check-empty",
|
|
"name": "Any Approved?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2,
|
|
"position": [650, 300],
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" },
|
|
"conditions": [
|
|
{
|
|
"id": "cond-has-data",
|
|
"leftValue": "={{ $json.data.length }}",
|
|
"rightValue": 0,
|
|
"operator": { "type": "number", "operation": "gt" }
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"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": {
|
|
"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"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"id": "uuid-execute-baramundi",
|
|
"name": "Execute Baramundi Job",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4,
|
|
"position": [1450, 100],
|
|
"parameters": {
|
|
"url": "https://baramundi-api.example.com/api/jobs",
|
|
"method": "POST",
|
|
"headers": {
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer YOUR_BARAMUNDI_TOKEN"
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({job_name: $json.baramundi_job, ticket_id: $json.ticket_id, description: $json.subject}) }}"
|
|
}
|
|
},
|
|
{
|
|
"id": "uuid-is-auto-reply",
|
|
"name": "Is Auto Reply?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2,
|
|
"position": [1450, 300],
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" },
|
|
"conditions": [
|
|
{
|
|
"id": "cond-autoreply",
|
|
"leftValue": "={{ $json.loesung_typ }}",
|
|
"rightValue": "AUTOMATISCHE_ANTWORT",
|
|
"operator": { "type": "string", "operation": "equals" }
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"id": "uuid-prepare-email",
|
|
"name": "Prepare Email Body",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1450, 200],
|
|
"parameters": {
|
|
"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> <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 • IT-Support • <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-send-reply",
|
|
"name": "Send Email Reply",
|
|
"type": "n8n-nodes-base.emailSend",
|
|
"typeVersion": 1,
|
|
"position": [1650, 200],
|
|
"parameters": {
|
|
"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" },
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"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\"}) }}"
|
|
}
|
|
},
|
|
{
|
|
"id": "uuid-log-audit",
|
|
"name": "Log to PostgreSQL",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4,
|
|
"position": [2450, 200],
|
|
"parameters": {
|
|
"url": "http://host.docker.internal:4000/query/audit",
|
|
"method": "POST",
|
|
"headers": { "Content-Type": "application/json" },
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"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}]]
|
|
},
|
|
"Get Approved Conversations": {
|
|
"main": [[{"node": "Any Approved?", "index": 0}]]
|
|
},
|
|
"Any Approved?": {
|
|
"main": [
|
|
[{"node": "Split into Items", "index": 0}],
|
|
[{"node": "No Approved Tickets", "index": 0}]
|
|
]
|
|
},
|
|
"Split into Items": {
|
|
"main": [[{"node": "Parse Suggestion", "index": 0}]]
|
|
},
|
|
"Parse Suggestion": {
|
|
"main": [[{"node": "Is Baramundi Job?", "index": 0}]]
|
|
},
|
|
"Is Baramundi Job?": {
|
|
"main": [
|
|
[{"node": "Execute Baramundi Job", "index": 0}],
|
|
[{"node": "Is Auto Reply?", "index": 0}]
|
|
]
|
|
},
|
|
"Is Auto Reply?": {
|
|
"main": [
|
|
[{"node": "Prepare Email Body", "index": 0}],
|
|
[{"node": "Mark Escalation", "index": 0}]
|
|
]
|
|
},
|
|
"Prepare Email Body": {
|
|
"main": [[{"node": "Send Email Reply", "index": 0}]]
|
|
},
|
|
"Execute Baramundi Job": {
|
|
"main": [[{"node": "Update Status to EXECUTED", "index": 0}]]
|
|
},
|
|
"Send Email Reply": {
|
|
"main": [[{"node": "Log Reply to Freescout", "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": "Log to PostgreSQL", "index": 0}]]
|
|
}
|
|
},
|
|
"active": false,
|
|
"settings": {
|
|
"errorHandler": "continueOnError"
|
|
}
|
|
}
|