commit 6244a65b99040523f0f74bacfa44b62c9765c934 Author: Alex Levanovich Date: Mon May 4 08:06:37 2026 +0300 Initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/IMG_0002.jpg b/IMG_0002.jpg new file mode 100644 index 0000000..828937c Binary files /dev/null and b/IMG_0002.jpg differ diff --git a/description.md b/description.md new file mode 100644 index 0000000..245e947 --- /dev/null +++ b/description.md @@ -0,0 +1,10 @@ +В инфраструктуре предприятия есть сервер exchange. Все пользователи получают и отправляют почту через него. Каждый пользователь авторизуется доменной учетной записью. +Сообщения о неблагоприятных погодных условиях приходят от адресата iaocms@sevmeteo.ru. Сообщения поступают на эл.почту disp5@appm.ru. +Тема сообщения "Предупреждение о НМУ". +Так же к этому же сообщению прикладывается файл (скан). Пример лежит в этой же папке - IMG_0002.jpg. Имена приложенных файлов могут отличаться. + +Что необходимо? +1. Надо скачать файл из письма. +2. Распознать текст. Вытащить из шапки дату сообщения, исходящий номер (остальные данные не требуются). Вытащить текст сообщения и тела скана письма. +3. Разослать текст нескольким адресатам по эл.почте. Данные исходящего сервера - 10.0.3.22, порт 25, без авторизации. + diff --git a/n8n_workflow.json b/n8n_workflow.json new file mode 100644 index 0000000..424c285 --- /dev/null +++ b/n8n_workflow.json @@ -0,0 +1,277 @@ +{ + "name": "NMU warning mail OCR and forward", + "nodes": [ + { + "parameters": { + "content": "Настройте credentials для IMAP/Exchange ящика disp5@appm.ru.\n\nФильтры workflow:\n- From: iaocms@sevmeteo.ru\n- Subject: Предупреждение о НМУ\n\nЛокальная модель:\n- POST http://10.1.1.1:4000/v1/chat/completions\n- model: QWEN3.5-9B\n\nSMTP рассылка:\n- 10.0.3.22:25\n- без авторизации\n\nВ узле Build Forward Email замените список получателей NMU_RECIPIENTS.", + "height": 320, + "width": 420 + }, + "id": "b3d55a4f-6f6c-4e58-840c-7770e5019111", + "name": "README", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -760, + -280 + ] + }, + { + "parameters": { + "mailbox": "INBOX", + "downloadAttachments": true, + "options": { + "customEmailConfig": "UNSEEN" + } + }, + "id": "e73a579a-3a21-4684-96b0-e1d817208b57", + "name": "IMAP Email Trigger", + "type": "n8n-nodes-base.emailReadImap", + "typeVersion": 2, + "position": [ + -760, + 160 + ], + "credentials": { + "imap": { + "id": "", + "name": "disp5@appm.ru IMAP" + } + } + }, + { + "parameters": { + "jsCode": "const FROM = 'iaocms@sevmeteo.ru';\nconst SUBJECT = 'Предупреждение о НМУ';\n\nreturn $input.all().map((item) => {\n const json = item.json || {};\n const fromText = String(json.from?.value?.[0]?.address || json.from?.text || json.from || '').toLowerCase();\n const subject = String(json.subject || '');\n const binaryKeys = Object.keys(item.binary || {});\n\n const matched = fromText.includes(FROM) && subject.trim() === SUBJECT && binaryKeys.length > 0;\n\n return {\n json: {\n ...json,\n nmu_should_process: matched,\n nmu_filter_reason: matched ? 'matched' : `from=${fromText}; subject=${subject}; attachments=${binaryKeys.length}`,\n nmu_binary_keys: binaryKeys\n },\n binary: item.binary\n };\n});" + }, + "id": "6ea5d2c9-ed64-4e35-bab2-492482df028e", + "name": "Filter NMU Message", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -520, + 160 + ] + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": "={{ $json.nmu_should_process }}", + "value2": true + } + ] + } + }, + "id": "86befd17-05e7-46c3-b16d-c5b67b820fe3", + "name": "Should Process?", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + -280, + 160 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst binary = item.binary || {};\nconst imageKey = Object.keys(binary).find((key) => {\n const mime = String(binary[key].mimeType || '').toLowerCase();\n return mime.startsWith('image/');\n});\n\nif (!imageKey) {\n throw new Error('В письме нет вложения-изображения. Если приходят PDF/TIFF, добавьте конвертацию перед OCR.');\n}\n\nconst attachment = binary[imageKey];\nconst mimeType = attachment.mimeType || 'image/jpeg';\nconst dataUrl = `data:${mimeType};base64,${attachment.data}`;\n\nconst openaiBody = {\n model: 'QWEN3.5-9B',\n temperature: 0,\n messages: [\n {\n role: 'system',\n content: 'Ты извлекаешь данные из скана официального письма. Верни только валидный JSON без markdown, пояснений и комментариев.'\n },\n {\n role: 'user',\n content: [\n {\n type: 'text',\n text: 'Распознай документ на изображении. Нужно извлечь: дату письма рядом с исходящим номером, исходящий номер после символа №, основной текст предупреждения НМУ и полный распознанный текст. Верни только валидный JSON в формате {\"date\":\"ДД.ММ.ГГГГ или null\",\"outgoing_number\":\"строка или null\",\"warning_text\":\"строка\",\"full_text\":\"строка\"}. Если поле не найдено, верни null. Не добавляй markdown.'\n },\n {\n type: 'image_url',\n image_url: {\n url: dataUrl\n }\n }\n ]\n }\n ]\n};\n\nreturn [{\n json: {\n message_id: item.json.messageId || item.json.message_id || null,\n original_subject: item.json.subject || '',\n original_from: item.json.from || '',\n attachment_key: imageKey,\n attachment_file_name: attachment.fileName || imageKey,\n attachment_mime_type: mimeType,\n openai_body: JSON.stringify(openaiBody)\n },\n binary: item.binary\n}];" + }, + "id": "753f7228-2e78-4192-b82a-e3f6da3cbe9d", + "name": "Prepare Qwen Vision Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -20, + 40 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.1.1.1:4000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "application/json", + "body": "={{ $json.openai_body }}", + "options": { + "timeout": 180000 + } + }, + "id": "c8992117-91e8-4855-9f71-089d2db07d6d", + "name": "OCR via QWEN3.5-9B", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 240, + 40 + ] + }, + { + "parameters": { + "jsCode": "function extractJson(text) {\n const clean = String(text || '').trim()\n .replace(/^```json\\s*/i, '')\n .replace(/^```\\s*/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n return JSON.parse(clean);\n } catch (error) {\n const match = clean.match(/\\{[\\s\\S]*\\}/);\n if (match) return JSON.parse(match[0]);\n throw error;\n }\n}\n\nconst response = $json;\nconst content = response.choices?.[0]?.message?.content || response.choices?.[0]?.text || response.content || '';\nconst parsed = extractJson(content);\n\nconst fullText = String(parsed.full_text || '');\nconst dateMatch = fullText.match(/\\b\\d{2}\\.\\d{2}\\.\\d{4}\\b/);\nconst numberMatch = fullText.match(/№\\s*([А-ЯA-Z0-9][А-ЯA-Z0-9\\/-]*)/i);\n\nconst date = parsed.date || dateMatch?.[0] || null;\nconst outgoingNumber = parsed.outgoing_number || numberMatch?.[1] || null;\n\nreturn [{\n json: {\n date,\n outgoing_number: outgoingNumber,\n warning_text: parsed.warning_text || null,\n full_text: parsed.full_text || content,\n raw_model_content: content\n }\n}];" + }, + "id": "6f2e9dcc-dfc3-4838-8c42-8988fc433559", + "name": "Parse OCR JSON", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 500, + 40 + ] + }, + { + "parameters": { + "jsCode": "const NMU_RECIPIENTS = [\n 'recipient1@appm.ru',\n 'recipient2@appm.ru'\n];\n\nconst date = $json.date || 'дата не распознана';\nconst number = $json.outgoing_number || 'номер не распознан';\nconst warningText = $json.warning_text || $json.full_text || 'Текст предупреждения не распознан.';\n\nconst subject = `Предупреждение о НМУ от ${date} № ${number}`;\nconst text = [\n `Дата письма: ${date}`,\n `Исходящий номер: ${number}`,\n '',\n 'Текст предупреждения:',\n warningText,\n '',\n 'Полный распознанный текст:',\n $json.full_text || ''\n].join('\\n');\n\nreturn [{\n json: {\n to_email: NMU_RECIPIENTS.join(','),\n from_email: 'disp5@appm.ru',\n subject,\n text,\n date: $json.date,\n outgoing_number: $json.outgoing_number\n }\n}];" + }, + "id": "af0f0426-8e65-4040-ba56-3450c1d223f0", + "name": "Build Forward Email", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 760, + 40 + ] + }, + { + "parameters": { + "fromEmail": "={{ $json.from_email }}", + "toEmail": "={{ $json.to_email }}", + "subject": "={{ $json.subject }}", + "emailFormat": "text", + "text": "={{ $json.text }}", + "options": {} + }, + "id": "3ff0b578-b265-4267-944f-8cb95e4a262a", + "name": "Send SMTP Email", + "type": "n8n-nodes-base.emailSend", + "typeVersion": 2.1, + "position": [ + 1020, + 40 + ], + "credentials": { + "smtp": { + "id": "", + "name": "SMTP 10.0.3.22 no auth" + } + } + }, + { + "parameters": { + "content": "Если узел HTTP Request вернет ошибку про image_url/base64, значит локальный OpenAI-compatible сервер не принимает data URL. Тогда нужен промежуточный HTTP-доступ к файлу или отдельный OCR endpoint, который принимает multipart/form-data.", + "height": 180, + "width": 360 + }, + "id": "f0631818-7c64-43dd-b901-08af5eb3de92", + "name": "Qwen note", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 160, + -220 + ] + } + ], + "pinData": {}, + "connections": { + "IMAP Email Trigger": { + "main": [ + [ + { + "node": "Filter NMU Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Filter NMU Message": { + "main": [ + [ + { + "node": "Should Process?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Should Process?": { + "main": [ + [ + { + "node": "Prepare Qwen Vision Request", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "Prepare Qwen Vision Request": { + "main": [ + [ + { + "node": "OCR via QWEN3.5-9B", + "type": "main", + "index": 0 + } + ] + ] + }, + "OCR via QWEN3.5-9B": { + "main": [ + [ + { + "node": "Parse OCR JSON", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse OCR JSON": { + "main": [ + [ + { + "node": "Build Forward Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Forward Email": { + "main": [ + [ + { + "node": "Send SMTP Email", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "saveManualExecutions": true, + "callerPolicy": "workflowsFromSameOwner" + }, + "versionId": "68b03930-1a27-48e3-8a17-2c61433e4800", + "meta": { + "templateCredsSetupCompleted": false + }, + "id": "NMU_QWEN_LOCAL_OCR", + "tags": [] +}