- kb_writer.py: DEVONthink AppleScript 브릿지 → 마크다운 파일 기반 전환 (포트 8095) - knowledge-base/ 디렉토리 구조 (note, chat-memory, news) - Handle Note: kb_writer 파일 저장 + Qdrant 임베딩 추가 - Embed & Save Memory: DEVONthink → kb_writer 교체 - mail_bridge.py: IMAP 날짜 기반 메일 조회 (포트 8094) - mail-processing-pipeline: IMAP Trigger → Schedule + mail_bridge + dedup - docker-compose, manage_services, LaunchAgent plist 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
280 lines
12 KiB
JSON
280 lines
12 KiB
JSON
{
|
|
"id": "mPmailprocess001",
|
|
"name": "메일 처리 파이프라인",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"rule": {
|
|
"interval": [
|
|
{
|
|
"field": "minutes",
|
|
"minutesInterval": 15
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000010",
|
|
"name": "Schedule Trigger",
|
|
"type": "n8n-nodes-base.scheduleTrigger",
|
|
"typeVersion": 1.2,
|
|
"position": [
|
|
0,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "GET",
|
|
"url": "={{ $env.MAIL_BRIDGE_URL }}/recent?days=1",
|
|
"options": {
|
|
"timeout": 15000
|
|
}
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000011",
|
|
"name": "Fetch Mails",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
220,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "SELECT message_id FROM mail_logs WHERE mail_date >= NOW() - INTERVAL '2 days'",
|
|
"options": {}
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000012",
|
|
"name": "Get Existing IDs",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
440,
|
|
300
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const mailsResponse = $('Fetch Mails').first().json;\nconst mails = mailsResponse.mails || [];\nconst existingRows = $input.all();\nconst existingIds = new Set(existingRows.map(r => r.json.message_id).filter(Boolean));\n\nconst results = [];\nfor (const mail of mails) {\n const mid = mail.messageId || '';\n if (!mid || existingIds.has(mid)) continue;\n results.push({ json: {\n from: mail.from || '',\n subject: (mail.subject || '').substring(0, 500),\n body: (mail.text || '').substring(0, 5000).replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim(),\n mailDate: mail.date || new Date().toISOString(),\n messageId: mid\n }});\n}\nreturn results;"
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000002",
|
|
"name": "Parse & Filter New",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
660,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst item = $input.first().json;\nconst prompt = `메일을 분류하고 요약하세요. JSON만 출력.\n\n{\n \"summary\": \"한국어 2~3문장 요약\",\n \"label\": \"업무|개인|광고|알림\",\n \"has_events\": true/false,\n \"has_tasks\": true/false\n}\n\n보낸 사람: ${item.from}\n제목: ${item.subject}\n본문: ${item.body.substring(0, 3000)}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', system: '/no_think', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n return [{ json: { ...item, summary: cls.summary || item.subject, label: cls.label || '알림', has_events: cls.has_events || false, has_tasks: cls.has_tasks || false } }];\n} catch(e) {\n return [{ json: { ...item, summary: item.subject, label: '알림', has_events: false, has_tasks: false } }];\n}"
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000003",
|
|
"name": "Summarize & Classify",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
880,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "=INSERT INTO mail_logs (from_address,subject,summary,label,has_events,has_tasks,mail_date,message_id) VALUES ('{{ ($json.from||'').replace(/'/g,\"''\").substring(0,255) }}','{{ ($json.subject||'').replace(/'/g,\"''\").substring(0,500) }}','{{ ($json.summary||'').replace(/'/g,\"''\").substring(0,2000) }}','{{ $json.label }}',{{ $json.has_events }},{{ $json.has_tasks }},'{{ $json.mailDate }}','{{ ($json.messageId||'').replace(/'/g,\"''\").substring(0,500) }}') ON CONFLICT (message_id) DO NOTHING",
|
|
"options": {}
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000004",
|
|
"name": "Save to mail_logs",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
1100,
|
|
300
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst item = $input.first().json;\nconst embText = `${item.subject} ${item.summary}`;\n\ntry {\n const emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model: 'bge-m3', prompt: embText });\n if (emb.embedding) {\n const qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n await httpPut(`${qdrantUrl}/collections/documents/points`, { points: [{ id: Date.now(), vector: emb.embedding, payload: {\n text: embText, source: 'mail', from_address: item.from,\n mail_date: item.mailDate, label: item.label,\n created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\n// DEVONthink 저장 (graceful)\ntry {\n const dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\n await httpPost(`${dtUrl}/save`, {\n title: item.subject, content: item.summary,\n type: 'markdown', tags: ['mail', item.label]\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{ json: { ...item, embedded: true } }];"
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000005",
|
|
"name": "Embed & Save",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
1320,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "important-check",
|
|
"leftValue": "={{ $json.label }}",
|
|
"rightValue": "업무",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
},
|
|
{
|
|
"id": "tasks-check",
|
|
"leftValue": "={{ $json.has_tasks }}",
|
|
"rightValue": true,
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "true"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000006",
|
|
"name": "Is Important?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
1540,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={ \"text\": {{ JSON.stringify('[메일 알림] ' + $json.from + ': ' + $json.subject + '\\n' + $json.summary) }} }",
|
|
"options": {
|
|
"timeout": 10000
|
|
}
|
|
},
|
|
"id": "m1000001-0000-0000-0000-000000000007",
|
|
"name": "Notify Chat",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1760,
|
|
200
|
|
]
|
|
}
|
|
],
|
|
"connections": {
|
|
"Schedule Trigger": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Fetch Mails",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Fetch Mails": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Get Existing IDs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Get Existing IDs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parse & Filter New",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parse & Filter New": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Summarize & Classify",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Summarize & Classify": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Save to mail_logs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Save to mail_logs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Embed & Save",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Embed & Save": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Is Important?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Is Important?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Notify Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
}
|
|
}
|