{ "id": "dailyEmailNotificationTemplate", "name": "Daily IMAP Email Summary to Telegram (Local Ollama)", "nodes": [ { "parameters": {}, "id": "manual_trigger", "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ -768, 92 ] }, { "parameters": { "rule": { "interval": [ { "triggerAtHour": 23, "triggerAtMinute": 59 } ] } }, "id": "schedule_trigger", "name": "Schedule Trigger", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [ -768, 284 ] }, { "parameters": { "command": "python3 - <<'PY'\nimport datetime\nimport email\nimport imaplib\nimport json\nimport re\nfrom collections import OrderedDict\nfrom email.header import decode_header\nfrom email.utils import parsedate_to_datetime\nfrom html.parser import HTMLParser\n\nUSER = 'YOUR_EMAIL@example.com'\nPASSWORD = 'YOUR_APP_PASSWORD'.replace(' ', '')\nHOST = 'YOUR_IMAP_HOST'\nPORT = 993\nSOCKET_TIMEOUT_SECS = 60\nMAX_WARNINGS = 20\nMAX_BODY_CHARS = 2000\nMAX_MESSAGES_PER_THREAD = 6\nMAX_THREAD_TEXT_CHARS = 6000\n\nSUBJECT_PREFIX_RE = re.compile(r'^((re|fw|fwd|aw|sv)\\s*:\\s*)+', re.I)\nURL_RE = re.compile(r'https?://\\S+|www\\.\\S+', re.I)\nWHITESPACE_RE = re.compile(r'\\s+')\nNOISE_RE = re.compile(r'(unsubscribe|manage preferences|view in browser|privacy policy|terms of service)', re.I)\n\n\ndef safe_text(value):\n if value is None:\n return ''\n try:\n return str(value)\n except Exception:\n return ''\n\n\ndef decode_part(part, enc):\n if isinstance(part, str):\n return part\n if not isinstance(part, (bytes, bytearray)):\n return safe_text(part)\n\n candidates = []\n if enc:\n normalized = safe_text(enc).strip().lower()\n if normalized in {'unknown-8bit', 'unknown', 'x-unknown'}:\n candidates.extend(['utf-8', 'latin-1'])\n else:\n candidates.append(safe_text(enc))\n\n candidates.extend(['utf-8', 'latin-1'])\n seen = set()\n for candidate in candidates:\n if not candidate or candidate in seen:\n continue\n seen.add(candidate)\n try:\n return bytes(part).decode(candidate, 'replace')\n except Exception:\n continue\n\n return bytes(part).decode('utf-8', 'replace')\n\n\ndef decode_value(value):\n if not value:\n return ''\n try:\n decoded = decode_header(value)\n except Exception:\n return safe_text(value)\n\n parts = []\n for item in decoded:\n try:\n if isinstance(item, tuple) and len(item) == 2:\n chunk, enc = item\n else:\n chunk, enc = item, None\n parts.append(decode_part(chunk, enc))\n except Exception:\n parts.append(safe_text(item))\n return ''.join(parts).strip()\n\n\ndef record_warning(warnings, stage, exc, context=''):\n if len(warnings) >= MAX_WARNINGS:\n return\n warnings.append({'stage': stage, 'error': safe_text(exc), 'context': safe_text(context)})\n\n\nclass HTMLTextExtractor(HTMLParser):\n BLOCK_TAGS = {'br', 'p', 'div', 'li', 'tr', 'table', 'section', 'article'}\n SKIP_TAGS = {'script', 'style', 'head', 'svg', 'noscript'}\n\n def __init__(self):\n super().__init__()\n self.parts = []\n self.skip_depth = 0\n\n def handle_starttag(self, tag, attrs):\n tag = (tag or '').lower()\n if tag in self.SKIP_TAGS:\n self.skip_depth += 1\n return\n if not self.skip_depth and tag in self.BLOCK_TAGS:\n self.parts.append('\\n')\n\n def handle_endtag(self, tag):\n tag = (tag or '').lower()\n if tag in self.SKIP_TAGS and self.skip_depth:\n self.skip_depth -= 1\n return\n if not self.skip_depth and tag in self.BLOCK_TAGS:\n self.parts.append('\\n')\n\n def handle_data(self, data):\n if self.skip_depth:\n return\n if data:\n self.parts.append(data)\n\n def text(self):\n return ''.join(self.parts)\n\n\ndef collapse_text(text):\n text = safe_text(text)\n text = text.replace('\\u00a0', ' ')\n text = text.replace('\\r', '\\n')\n text = URL_RE.sub(' ', text)\n lines = []\n for raw_line in text.split('\\n'):\n cleaned = WHITESPACE_RE.sub(' ', raw_line).strip()\n if not cleaned:\n continue\n if NOISE_RE.search(cleaned) and len(cleaned) < 160:\n continue\n lines.append(cleaned)\n collapsed = '\\n'.join(lines).strip()\n if len(collapsed) > MAX_BODY_CHARS:\n trimmed = collapsed[:MAX_BODY_CHARS].rstrip()\n if ' ' in trimmed:\n trimmed = trimmed.rsplit(' ', 1)[0]\n collapsed = trimmed.rstrip(' .,;:') + ' ...'\n return collapsed\n\n\ndef html_to_text(raw_html):\n parser = HTMLTextExtractor()\n try:\n parser.feed(raw_html)\n parser.close()\n except Exception:\n return collapse_text(raw_html)\n return collapse_text(parser.text())\n\n\ndef extract_body_text(msg):\n plain_parts = []\n html_parts = []\n\n def add_part(content_type, payload, charset):\n if not payload:\n return\n decoded = decode_part(payload, charset)\n if content_type == 'text/plain':\n plain_parts.append(decoded)\n elif content_type == 'text/html':\n html_parts.append(decoded)\n\n if msg.is_multipart():\n for part in msg.walk():\n disposition = safe_text(part.get('Content-Disposition')).lower()\n if 'attachment' in disposition:\n continue\n content_type = safe_text(part.get_content_type()).lower()\n if content_type not in {'text/plain', 'text/html'}:\n continue\n try:\n payload = part.get_payload(decode=True)\n except Exception:\n payload = None\n add_part(content_type, payload, part.get_content_charset())\n else:\n try:\n payload = msg.get_payload(decode=True)\n except Exception:\n payload = None\n add_part(safe_text(msg.get_content_type()).lower(), payload, msg.get_content_charset())\n\n plain_text = collapse_text('\\n\\n'.join(plain_parts)) if plain_parts else ''\n html_text = html_to_text('\\n\\n'.join(html_parts)) if html_parts else ''\n\n if len(plain_text) >= 40:\n return plain_text\n return html_text or plain_text\n\n\ndef normalize_subject(subject):\n subject = collapse_text(subject)\n while True:\n stripped = SUBJECT_PREFIX_RE.sub('', subject).strip()\n if stripped == subject:\n return stripped or '(no subject)'\n subject = stripped\n\n\ndef parse_date(value, warnings):\n raw = decode_value(value)\n if not raw:\n return None, None\n try:\n dt = parsedate_to_datetime(raw)\n if dt.tzinfo is None:\n dt = dt.replace(tzinfo=datetime.timezone.utc)\n dt_utc = dt.astimezone(datetime.timezone.utc)\n return raw, dt_utc\n except Exception as exc:\n record_warning(warnings, 'parse_date', exc, raw)\n return raw, None\n\n\ndef build_thread_key(msg, normalized_subject, message_id, imap_id):\n references = decode_value(msg.get('References'))\n if references:\n first_ref = references.split()[0].strip('<>')\n if first_ref:\n return 'ref:' + first_ref, references, decode_value(msg.get('In-Reply-To'))\n\n in_reply_to = decode_value(msg.get('In-Reply-To'))\n if in_reply_to:\n return 'reply:' + in_reply_to.strip('<>'), references, in_reply_to\n\n if normalized_subject:\n return 'subj:' + normalized_subject.lower(), references, in_reply_to\n\n if message_id:\n return 'msg:' + message_id.strip('<>'), references, in_reply_to\n\n return 'imap:' + imap_id, references, in_reply_to\n\n\ncutoff_utc = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=24)\nsince_date = cutoff_utc.strftime('%d-%b-%Y')\nmail = None\nwarnings = []\nmessages = []\nseen_messages = set()\n\ntry:\n mail = imaplib.IMAP4_SSL(HOST, PORT, timeout=SOCKET_TIMEOUT_SECS)\n mail.login(USER, PASSWORD)\n mail.select('INBOX')\n\n status, data = mail.search(None, f'(SINCE \"{since_date}\")')\n message_ids = data[0].split() if status == 'OK' and data and data[0] else []\n\n for message_id in message_ids:\n imap_id = message_id.decode(errors='ignore')\n try:\n fetch_status, msg_data = mail.fetch(message_id, '(BODY.PEEK[])')\n if fetch_status != 'OK' or not msg_data:\n continue\n\n raw_msg = None\n for part in msg_data:\n if isinstance(part, tuple) and len(part) > 1:\n raw_msg = part[1]\n break\n if not raw_msg:\n continue\n\n try:\n msg = email.message_from_bytes(raw_msg)\n except Exception as exc:\n record_warning(warnings, 'parse_message', exc, imap_id)\n continue\n\n raw_date, parsed_dt_utc = parse_date(msg.get('Date'), warnings)\n if parsed_dt_utc is not None and parsed_dt_utc < cutoff_utc:\n continue\n\n subject = decode_value(msg.get('Subject')) or '(no subject)'\n normalized_subject = normalize_subject(subject)\n message_id_value = decode_value(msg.get('Message-ID'))\n from_value = decode_value(msg.get('From'))\n to_value = decode_value(msg.get('To'))\n body_text = extract_body_text(msg)\n thread_key, references_value, in_reply_to = build_thread_key(msg, normalized_subject, message_id_value, imap_id)\n dedupe_key = (message_id_value or imap_id, thread_key)\n if dedupe_key in seen_messages:\n continue\n seen_messages.add(dedupe_key)\n\n messages.append({\n 'imapId': imap_id,\n 'messageId': message_id_value,\n 'from': from_value,\n 'to': to_value,\n 'subject': subject,\n 'normalizedSubject': normalized_subject,\n 'date': raw_date,\n 'dateIsoUtc': parsed_dt_utc.isoformat() if parsed_dt_utc else None,\n 'references': references_value,\n 'inReplyTo': in_reply_to,\n 'threadKey': thread_key,\n 'bodyPreview': body_text,\n })\n except Exception as exc:\n record_warning(warnings, 'process_email', exc, imap_id)\n continue\nfinally:\n if mail is not None:\n try:\n mail.logout()\n except Exception:\n pass\n\nmessages.sort(key=lambda item: (item.get('dateIsoUtc') or '', item.get('imapId') or ''))\nthreads_map = OrderedDict()\nfor item in messages:\n thread = threads_map.setdefault(item['threadKey'], {\n 'threadKey': item['threadKey'],\n 'normalizedSubject': item.get('normalizedSubject') or normalize_subject(item.get('subject')),\n 'messages': [],\n })\n thread['messages'].append(item)\n\nthreads = []\nfor thread in threads_map.values():\n original_messages = list(thread['messages'])\n trimmed_messages = original_messages[-MAX_MESSAGES_PER_THREAD:]\n conversation_parts = []\n total_chars = 0\n for idx, msg in enumerate(trimmed_messages, start=1):\n block = [\n f'Message {idx}',\n 'Date: ' + safe_text(msg.get('dateIsoUtc') or msg.get('date') or '-'),\n 'From: ' + safe_text(msg.get('from') or '-'),\n 'Subject: ' + safe_text(msg.get('subject') or '-'),\n 'Body: ' + safe_text(msg.get('bodyPreview') or '(no readable body)'),\n ]\n chunk = '\\n'.join(block)\n if total_chars and total_chars + len(chunk) + 2 > MAX_THREAD_TEXT_CHARS:\n break\n conversation_parts.append(chunk)\n total_chars += len(chunk) + 2\n\n latest = original_messages[-1]\n thread_payload = {\n 'threadKey': thread['threadKey'],\n 'normalizedSubject': thread['normalizedSubject'],\n 'latestSubject': latest.get('subject') or thread['normalizedSubject'],\n 'latestFrom': latest.get('from') or '',\n 'firstDateIsoUtc': original_messages[0].get('dateIsoUtc'),\n 'lastDateIsoUtc': latest.get('dateIsoUtc'),\n 'messageCount': len(original_messages),\n 'messages': trimmed_messages,\n 'conversationText': '\\n\\n'.join(conversation_parts).strip(),\n }\n threads.append(thread_payload)\n\nthreads.sort(key=lambda item: item.get('lastDateIsoUtc') or '', reverse=True)\n\nresult = {\n 'generatedAtUtc': datetime.datetime.now(datetime.timezone.utc).isoformat(),\n 'windowHours': 24,\n 'count': len(messages),\n 'threadCount': len(threads),\n 'emails': messages,\n 'threads': threads,\n}\n\nif warnings:\n result['warningCount'] = len(warnings)\n result['warnings'] = warnings\n\nprint(json.dumps(result, ensure_ascii=False))\nPY" }, "id": "fetch_Email_imap", "name": "Fetch Gmail via IMAP", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ -544, 188 ], "executeOnce": true, "retryOnFail": true, "maxTries": 4, "waitBetweenTries": 5000, "notesInFlow": true, "notes": "Configure USER, PASSWORD, and HOST in this node. For Gmail, use an app password and HOST=imap.gmail.com." }, { "parameters": { "jsCode": "const raw = ($json.stdout ?? '').trim();\nif (!raw) {\n return [{ json: { generatedAtUtc: new Date().toISOString(), windowHours: 24, count: 0, emails: [], note: 'No stdout from command', stderr: $json.stderr ?? '' } }];\n}\n\nlet parsed;\ntry {\n parsed = JSON.parse(raw);\n} catch (error) {\n throw new Error(`Failed to parse command stdout as JSON: ${raw.slice(0, 500)}`);\n}\n\nreturn [{ json: parsed }];" }, "id": "parse_output", "name": "Parse JSON Output", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -320, 188 ] }, { "parameters": { "jsCode": "const payload = $json ?? {};\nconst threadsInput = Array.isArray(payload.threads) ? payload.threads : [];\nconst emails = Array.isArray(payload.emails) ? payload.emails : [];\nconst totalEmailCount = Number(payload.count ?? emails.length ?? 0);\nconst totalThreadCount = Number(payload.threadCount ?? threadsInput.length ?? 0);\n\nconst clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();\nconst clip = (value, max) => {\n const text = clean(value);\n if (!text || text.length <= max) return text;\n const trimmed = text.slice(0, max).trim();\n return trimmed.replace(/[\\s.,;:]+$/g, '') + ' ...';\n};\nconst dedupeBy = (items, keyFn) => {\n const seen = new Set();\n return items.filter((item) => {\n const key = keyFn(item);\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n};\n\nconst synthThreads = emails.map((email) => ({\n threadKey: email.threadKey || email.messageId || email.imapId || clean(email.subject) || 'single',\n normalizedSubject: clean(email.normalizedSubject || email.subject || '(no subject)'),\n latestSubject: clean(email.subject || '(no subject)'),\n latestFrom: clean(email.from || ''),\n firstDateIsoUtc: email.dateIsoUtc || null,\n lastDateIsoUtc: email.dateIsoUtc || null,\n messageCount: 1,\n messages: [email],\n conversationText: [\n `Message 1`,\n `Date: ${clean(email.dateIsoUtc || email.date || '-')}`,\n `From: ${clean(email.from || '-')}`,\n `Subject: ${clean(email.subject || '(no subject)')}`,\n `Body: ${clean(email.bodyPreview || '(no readable body)')}`,\n ].join('\\n'),\n}));\n\nconst threads = (threadsInput.length ? threadsInput : synthThreads)\n .map((thread, index) => ({\n index,\n threadKey: clean(thread.threadKey || `thread-${index + 1}`),\n normalizedSubject: clean(thread.normalizedSubject || thread.latestSubject || '(no subject)'),\n latestSubject: clean(thread.latestSubject || thread.normalizedSubject || '(no subject)'),\n latestFrom: clean(thread.latestFrom || thread.messages?.slice(-1)?.[0]?.from || ''),\n messageCount: Number(thread.messageCount ?? (Array.isArray(thread.messages) ? thread.messages.length : 0) ?? 1),\n firstDateIsoUtc: clean(thread.firstDateIsoUtc || ''),\n lastDateIsoUtc: clean(thread.lastDateIsoUtc || ''),\n conversationText: clip(thread.conversationText || '', 6000),\n }))\n .filter((thread) => thread.latestSubject || thread.conversationText);\n\nconst blockedPatterns = [\n /job alert|jobalerts|recommended jobs?|is hiring|hiring digest|career opportunity|open roles?|bootcamp/i,\n /newsletter|digest|promo|promotion|offer|discount|deal|sale|coupon|advert|marketing/i,\n /loan|credit card|pre-?approved|ipo\\b|nfo\\b|mutual fund\\s*nfo|market digest|stock alert|webinar|contest|preorder/i,\n /people you may know|social update|linkedin (news|update|research|group|notification)/i,\n /welcome to|product update|release notes|new in|free trial reminder/i,\n];\nconst urgentPatterns = [\n /security alert|suspicious|verify|verification|otp|2fa|password|breach|sign-?in/i,\n /action required|urgent|deadline|due today|due tomorrow|expires?|expiry|renew/i,\n /confirm|approval|approve|reply required|respond by|complete your/i,\n /payment due|invoice|failed payment|billing issue|statement available/i,\n /meeting|rescheduled|calendar|client issue|incident|outage|support ending/i,\n];\nconst infoPatterns = [\n /statement|receipt|transaction confirmed|order shipped|delivery update|storage/i,\n /notice|policy update|digest|summary|market note|report/i,\n];\n\nconst classifyReason = (thread) => {\n const text = `${thread.latestSubject} ${thread.latestFrom} ${thread.conversationText}`;\n if (/security alert|verify|otp|2fa|password|sign-?in/i.test(text)) return 'Security verification or review needed';\n if (/confirm|approval|approve|reply required|respond by|complete your/i.test(text)) return 'Pending confirmation or reply from you';\n if (/payment due|invoice|failed payment|billing issue/i.test(text)) return 'Payment or billing action required';\n if (/meeting|rescheduled|calendar|deadline|due/i.test(text)) return 'Schedule or deadline requires attention';\n if (/renew|expires?|support ending/i.test(text)) return 'Expiry or renewal needs action';\n return 'Review and act on this thread';\n};\nconst infoNote = (thread) => {\n const text = `${thread.latestSubject} ${thread.latestFrom} ${thread.conversationText}`;\n if (/statement|receipt|transaction confirmed/i.test(text)) return 'Informational financial update';\n if (/delivery|order shipped/i.test(text)) return 'Delivery or order status update';\n if (/notice|policy update|report/i.test(text)) return 'Informational notice';\n return 'Useful for reference';\n};\n\nconst annotated = threads.map((thread) => {\n const haystack = `${thread.latestSubject} ${thread.latestFrom} ${thread.conversationText}`;\n const isBlocked = blockedPatterns.some((rx) => rx.test(haystack));\n const isUrgent = urgentPatterns.some((rx) => rx.test(haystack));\n const isInfo = !isBlocked && !isUrgent && infoPatterns.some((rx) => rx.test(haystack));\n return { ...thread, isBlocked, isUrgent, isInfo };\n});\n\nconst blockedThreads = annotated.filter((thread) => thread.isBlocked);\nconst candidateThreads = annotated.filter((thread) => !thread.isBlocked);\nconst urgentThreads = dedupeBy(candidateThreads.filter((thread) => thread.isUrgent), (thread) => thread.normalizedSubject.toLowerCase()).slice(0, 5);\nconst infoThreads = dedupeBy(candidateThreads.filter((thread) => !thread.isUrgent && thread.isInfo), (thread) => thread.normalizedSubject.toLowerCase()).slice(0, 3);\n\nconst maxCandidateThreads = 12;\nconst maxContextChars = 24000;\nlet consumedChars = 0;\nconst candidateThreadContext = [];\nfor (const thread of candidateThreads.slice(0, maxCandidateThreads)) {\n const block = [\n `Thread Key: ${thread.threadKey}`,\n `Normalized Subject: ${thread.normalizedSubject}`,\n `Latest Subject: ${thread.latestSubject}`,\n `Latest Sender: ${thread.latestFrom || '-'}`,\n `Message Count: ${thread.messageCount}`,\n `Last Email UTC: ${thread.lastDateIsoUtc || '-'}`,\n `Conversation:\\n${thread.conversationText || '(no readable body)'}`,\n ].join('\\n');\n if (consumedChars && consumedChars + block.length + 2 > maxContextChars) break;\n candidateThreadContext.push(block);\n consumedChars += block.length + 2;\n}\n\nconst blockedExamples = blockedThreads.slice(0, 4).map((thread) => `${thread.latestSubject} (${thread.latestFrom || 'unknown sender'})`);\nconst urgentItems = urgentThreads.map((thread) => ({\n subject: thread.latestSubject,\n sender: thread.latestFrom || 'Unknown sender',\n reason: classifyReason(thread),\n}));\nconst forInfo = infoThreads.map((thread) => ({\n subject: thread.latestSubject,\n sender: thread.latestFrom || 'Unknown sender',\n note: infoNote(thread),\n}));\n\nconst fallbackSummary = urgentItems.length\n ? `${urgentItems.length} email thread${urgentItems.length === 1 ? '' : 's'} need action from the last 24 hours.`\n : 'No actionable email threads in the last 24 hours.';\n\nconst fallbackStructured = {\n summary: fallbackSummary,\n urgent_items: urgentItems,\n for_info: forInfo,\n};\n\nconst dateLocal = new Intl.DateTimeFormat(undefined, {\n day: '2-digit',\n month: 'short',\n year: 'numeric',\n}).format(new Date());\n\nreturn {\n json: {\n ...payload,\n total_thread_count: totalThreadCount || threads.length,\n email_count_24h: totalEmailCount,\n blocked_thread_count: blockedThreads.length,\n candidate_thread_count: candidateThreads.length,\n blocked_examples_text: blockedExamples.join('; '),\n candidate_thread_context: candidateThreadContext.join('\\n\\n'),\n fallback_structured: fallbackStructured,\n ntfy_title: `Daily Email Summary | ${totalEmailCount} mails | ${dateLocal}`,\n },\n};", "mode": "runOnceForEachItem" }, "id": "5323cfa5-3107-4776-a5de-1ce8c1c36065", "name": "Build Summary Prompt", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -96, 188 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const base = $json || {};\nconst structured = base.structured_output || {};\nconst urgentItems = Array.isArray(base.urgent_items) ? base.urgent_items : Array.isArray(structured.urgent_items) ? structured.urgent_items : [];\nconst forInfo = Array.isArray(base.for_info) ? base.for_info : Array.isArray(structured.for_info) ? structured.for_info : [];\nconst summary = String(base.summary || structured.summary || 'Action needed on recent email threads.').trim();\nconst mutedCount = Number(base.blocked_thread_count ?? 0);\nconst mutedExamples = String(base.blocked_examples_text || '').trim();\n\nconst clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();\nconst clip = (value, max) => {\n const text = clean(value);\n if (!text || text.length <= max) return text;\n return text.slice(0, max).replace(/[\\s.,;:]+$/g, '') + ' ...';\n};\nconst escapeHtml = (value) => String(value ?? '').replace(/&/g, '&').replace(//g, '>');\nconst wrap = (value, width) => {\n const text = clean(value);\n if (!text) return [''];\n const tokens = text.split(/\\s+/);\n const lines = [];\n let current = '';\n for (const token of tokens) {\n if (token.length > width) {\n if (current) {\n lines.push(current);\n current = '';\n }\n for (let i = 0; i < token.length; i += width) lines.push(token.slice(i, i + width));\n continue;\n }\n if (!current) {\n current = token;\n continue;\n }\n if ((current + ' ' + token).length <= width) {\n current += ' ' + token;\n } else {\n lines.push(current);\n current = token;\n }\n }\n if (current) lines.push(current);\n return lines.length ? lines : [''];\n};\n\nconst columns = [\n { key: 'subject', width: 26 },\n { key: 'sender', width: 18 },\n { key: 'reason', width: 30 },\n];\nconst border = '+' + columns.map((c) => '-'.repeat(c.width + 2)).join('+') + '+';\nconst renderBlock = (row) => {\n const cellLines = columns.map((column) => wrap(row[column.key] ?? '', column.width));\n const height = Math.max(...cellLines.map((lines) => lines.length));\n return Array.from({ length: height }, (_, index) => {\n const cells = columns.map((column, cellIndex) => ` ${(cellLines[cellIndex][index] || '').padEnd(column.width, ' ')} `);\n return `|${cells.join('|')}|`;\n });\n};\nconst buildTable = (rows) => {\n if (!rows.length) return 'None';\n const lines = [border, ...renderBlock({ subject: 'Subject', sender: 'Sender', reason: 'Reason' }), border];\n for (const row of rows.slice(0, 5)) {\n lines.push(...renderBlock({\n subject: clip(row.subject, 240),\n sender: clip(row.sender, 140),\n reason: clip(row.reason, 220),\n }));\n lines.push(border);\n }\n return ['```', ...lines, '```'].join('\\n');\n};\n\nconst infoLines = forInfo.slice(0, 3).map((item) => `- ${clip(item.subject, 90)} | ${clip(item.sender, 60)} | ${clip(item.note, 90)}`);\nconst mutedLines = [\n `- ${mutedCount} thread${mutedCount === 1 ? '' : 's'} muted before analysis.`,\n mutedExamples ? `- Examples: ${clip(mutedExamples, 240)}` : '- Examples: None',\n];\n\nconst sections = [\n '📬 Daily Email Summary (Last 24h)',\n '🧠 Summary',\n summary,\n '',\n '✅ Action Required',\n buildTable(urgentItems),\n];\nif (infoLines.length) {\n sections.push('', 'ℹ️ For Info', ...infoLines);\n}\nsections.push('', '🧹 Muted', ...mutedLines);\n\nconst text = sections.join('\\n').trim();\nconst codeBlockRegex = /```(?:[a-zA-Z0-9_-]+)?\\n?([\\s\\S]*?)```/g;\nlet html = '';\nlet lastIndex = 0;\nlet match;\nwhile ((match = codeBlockRegex.exec(text)) !== null) {\n html += escapeHtml(text.slice(lastIndex, match.index));\n html += `
${escapeHtml(String(match[1] ?? '').replace(/^\\n+|\\n+$/g, ''))}
`;\n lastIndex = match.index + match[0].length;\n}\nhtml += escapeHtml(text.slice(lastIndex));\n\nreturn {\n json: {\n ...base,\n text,\n notify_message: text,\n telegram_message: html || escapeHtml(text),\n },\n};" }, "id": "94789405-6d69-4b6a-98f3-5ac1bba96e69", "name": "Normalize Summary Message", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1824, 188 ] }, { "parameters": { "method": "POST", "url": "https://ntfy.sh/YOUR_NTFY_TOPIC", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Title", "value": "={{$('Fix Action Table Layout').first().json.ntfy_title}}" }, { "name": "Priority", "value": "high" }, { "name": "Tags", "value": "mail" }, { "name": "Content-Type", "value": "text/plain; charset=utf-8" } ] }, "sendBody": true, "contentType": "raw", "rawContentType": "text/plain", "body": "={{$('Fix Action Table Layout').first().json.notify_message}}", "options": { "response": { "response": { "fullResponse": true } }, "timeout": 10000 } }, "id": "cbc60d0a-b446-4a1a-bbfc-113dccb38906", "name": "Send ntfy Summary", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 2496, 284 ], "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 1000, "notesInFlow": true, "notes": "Optional fallback. Replace YOUR_NTFY_TOPIC or remove this node if you do not want ntfy fallback." }, { "parameters": { "chatId": "YOUR_TELEGRAM_CHAT_ID", "text": "={{$json.telegram_message || $json.notify_message}}", "additionalFields": { "appendAttribution": false, "parse_mode": "HTML" }, "resource": "message", "operation": "sendMessage" }, "id": "telegram_send_summary", "name": "Send Telegram Summary", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 2048, 284 ], "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 3000, "continueOnFail": false, "onError": "continueRegularOutput", "notesInFlow": true, "notes": "Set YOUR_TELEGRAM_CHAT_ID and select/create your Telegram credential after import." }, { "parameters": { "conditions": { "options": { "version": 2, "leftValue": "", "caseSensitive": true, "typeValidation": "strict" }, "combinator": "and", "conditions": [ { "id": "telegram_error_check", "leftValue": "={{ !!$json.error }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ] }, "options": {} }, "id": "if_telegram_failed", "name": "Telegram Delivery Failed?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 2272, 284 ] }, { "parameters": { "jsCode": "const base = (() => {\n try {\n return $('Prepare Structured Prompt').first().json || {};\n } catch {\n return {};\n }\n})();\nconst payload = $json || {};\nconst fallback = base.fallback_structured || { summary: 'No actionable email threads in the last 24 hours.', urgent_items: [], for_info: [] };\n\nconst clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();\nconst clip = (value, max) => {\n const text = clean(value);\n if (!text || text.length <= max) return text;\n return text.slice(0, max).replace(/[\\s.,;:]+$/g, '') + ' ...';\n};\nconst normalizeItem = (item, noteKey) => ({\n subject: clip(item?.subject, 180),\n sender: clip(item?.sender, 140),\n [noteKey]: clip(item?.[noteKey], 90),\n});\nconst extractJsonObject = (text) => {\n const source = String(text || '').trim();\n if (!source) return null;\n try {\n return JSON.parse(source);\n } catch {}\n let depth = 0;\n let start = -1;\n let inString = false;\n let escaped = false;\n for (let i = 0; i < source.length; i += 1) {\n const ch = source[i];\n if (escaped) {\n escaped = false;\n continue;\n }\n if (ch === '\\\\' && inString) {\n escaped = true;\n continue;\n }\n if (ch === '\"') {\n inString = !inString;\n continue;\n }\n if (inString) continue;\n if (ch === '{') {\n if (depth === 0) start = i;\n depth += 1;\n } else if (ch === '}') {\n if (depth > 0) depth -= 1;\n if (depth === 0 && start !== -1) {\n const candidate = source.slice(start, i + 1);\n try {\n return JSON.parse(candidate);\n } catch {\n start = -1;\n }\n }\n }\n }\n return null;\n};\n\nconst rawCandidates = [\n payload?.message?.content,\n payload?.response?.message?.content,\n payload?.data?.message?.content,\n payload?.output,\n payload?.response,\n payload?.text,\n payload,\n];\n\nlet structured = null;\nlet rawModelText = '';\nfor (const candidate of rawCandidates) {\n if (!candidate) continue;\n if (typeof candidate === 'string') {\n rawModelText = rawModelText || candidate;\n const parsed = extractJsonObject(candidate);\n if (parsed) {\n structured = parsed;\n break;\n }\n continue;\n }\n if (typeof candidate === 'object') {\n const direct = candidate.summary || candidate.urgent_items || candidate.for_info ? candidate : null;\n if (direct) {\n structured = direct;\n break;\n }\n const nestedText = candidate?.message?.content || candidate?.content || '';\n if (nestedText && typeof nestedText === 'string') {\n rawModelText = rawModelText || nestedText;\n const parsed = extractJsonObject(nestedText);\n if (parsed) {\n structured = parsed;\n break;\n }\n }\n }\n}\n\nif (!structured || typeof structured !== 'object') {\n const urgentItems = Array.isArray(fallback.urgent_items) ? fallback.urgent_items : [];\n const forInfo = Array.isArray(fallback.for_info) ? fallback.for_info : [];\n return {\n json: {\n ...base,\n ...payload,\n structured_output: fallback,\n summary: clean(fallback.summary),\n urgent_items: urgentItems,\n for_info: forInfo,\n has_urgent_items: urgentItems.length > 0,\n summary_source: 'ollama_fallback_invalid_output',\n ollama_error: 'Model returned invalid JSON; heuristic fallback used.',\n raw_model_text: clip(rawModelText, 3000),\n },\n };\n}\n\nconst summary = clip(structured.summary || fallback.summary, 140);\nconst urgentItems = (Array.isArray(structured.urgent_items) ? structured.urgent_items : [])\n .map((item) => normalizeItem(item, 'reason'))\n .filter((item) => item.subject && item.sender && item.reason)\n .slice(0, 5);\nconst forInfo = (Array.isArray(structured.for_info) ? structured.for_info : [])\n .map((item) => normalizeItem(item, 'note'))\n .filter((item) => item.subject && item.sender && item.note)\n .slice(0, 3);\nconst normalized = {\n summary: summary || fallback.summary,\n urgent_items: urgentItems,\n for_info: forInfo,\n};\n\nreturn {\n json: {\n ...base,\n ...payload,\n structured_output: normalized,\n summary: normalized.summary,\n urgent_items: normalized.urgent_items,\n for_info: normalized.for_info,\n has_urgent_items: normalized.urgent_items.length > 0,\n summary_source: 'ollama_structured',\n ollama_error: '',\n raw_model_text: clip(rawModelText, 3000),\n },\n};", "mode": "runOnceForEachItem" }, "id": "parse_ollama_summary", "name": "Parse Ollama Summary", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1376, 64 ] }, { "parameters": { "jsCode": "return { json: { ...$json } };", "mode": "runOnceForEachItem" }, "id": "978c1978-83d4-4895-b916-08b0ae7bc1c9", "name": "Fix Action Table Layout", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2048, 188 ] }, { "id": "87957953-b1b8-48d5-a41a-902ce9bd9771", "name": "Prepare Structured Prompt", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 16, 188 ], "parameters": { "jsCode": "const source = $json || {};\nconst fallback = source.fallback_structured || { summary: 'No actionable email threads in the last 24 hours.', urgent_items: [], for_info: [] };\nconst systemPrompt = [\n 'You are an executive email triage assistant.',\n 'Return exactly one valid JSON object and nothing else.',\n 'Do not include markdown, code fences, commentary, or chain-of-thought.',\n 'Classify only real user-specific actions as urgent.',\n 'Promotions, job alerts, newsletters, market alerts, generic offers, social updates, and informational notices are not urgent unless the thread clearly requires a pending action from the user.',\n 'Use this exact schema:',\n '{',\n ' \"summary\": \"string\",',\n ' \"urgent_items\": [{\"subject\":\"string\",\"sender\":\"string\",\"reason\":\"string\"}],',\n ' \"for_info\": [{\"subject\":\"string\",\"sender\":\"string\",\"note\":\"string\"}]',\n '}',\n 'Rules:',\n '- summary must be one sentence under 140 characters',\n '- urgent_items must contain at most 5 items',\n '- for_info must contain at most 3 items',\n '- reason and note must each be under 90 characters',\n '- if there is no urgent item, return urgent_items as []',\n '- preserve the real subject and sender text from the input',\n '',\n 'Few-shot example input:',\n 'Thread Key: subj:google security alert',\n 'Normalized Subject: Google security alert',\n 'Latest Subject: Google security alert',\n 'Latest Sender: Google ',\n 'Message Count: 1',\n 'Last Email UTC: 2026-04-01T16:00:00+00:00',\n 'Conversation:',\n 'Message 1',\n 'Date: 2026-04-01T16:00:00+00:00',\n 'From: Google ',\n 'Subject: Google security alert',\n 'Body: New sign-in detected from a new device. Review activity and secure the account if this was not you.',\n '',\n 'Few-shot example output:',\n '{',\n ' \"summary\": \"One security email requires immediate review.\",',\n ' \"urgent_items\": [',\n ' {',\n ' \"subject\": \"Google security alert\",',\n ' \"sender\": \"Google \",',\n ' \"reason\": \"New sign-in alert requires account verification.\"',\n ' }',\n ' ],',\n ' \"for_info\": []',\n '}',\n].join('\\n');\n\nconst userPrompt = [\n 'Analyze the following last-24-hour email threads.',\n `Total emails: ${source.email_count_24h ?? 0}`,\n `Total threads: ${source.total_thread_count ?? 0}`,\n `Candidate threads: ${source.candidate_thread_count ?? 0}`,\n `Muted threads before model: ${source.blocked_thread_count ?? 0}`,\n source.blocked_examples_text ? `Muted examples: ${source.blocked_examples_text}` : 'Muted examples: None',\n '',\n 'Return only the JSON object.',\n '',\n source.candidate_thread_context || '(no candidate threads)',\n].join('\\n');\n\nreturn {\n json: {\n ...source,\n system_prompt: systemPrompt,\n user_prompt: userPrompt,\n prompt_text: `${systemPrompt}\\n\\n${userPrompt}`,\n },\n};", "mode": "runOnceForEachItem" } }, { "id": "b6ff7d14-3687-4a4c-a854-ac4184c670f6", "name": "Call Ollama Summary API", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1024, 64 ], "parameters": { "method": "POST", "url": "http://127.0.0.1:11434/api/chat", "sendBody": true, "contentType": "raw", "rawContentType": "application/json", "body": "={{ JSON.stringify({ model: 'YOUR_OLLAMA_MODEL', stream: false, think: false, format: 'json', keep_alive: '15m', messages: [{ role: 'system', content: $json.system_prompt }, { role: 'user', content: $json.user_prompt }], options: { temperature: 0, num_ctx: 32768, num_predict: 260 } }) }}", "options": { "timeout": 90000 } }, "onError": "continueRegularOutput", "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 1500, "notesInFlow": true, "notes": "Set YOUR_OLLAMA_MODEL to a locally available Ollama model and make sure Ollama is reachable at the configured URL." }, { "id": "if_has_urgent_items", "name": "Has Urgent Items?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 1600, 64 ], "parameters": { "conditions": { "options": { "version": 2, "leftValue": "", "caseSensitive": true, "typeValidation": "strict" }, "combinator": "and", "conditions": [ { "id": "has_urgent_items_check", "leftValue": "={{ !!$json.has_urgent_items }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ] }, "options": {} } } ], "connections": { "Manual Trigger": { "main": [ [ { "node": "Fetch Gmail via IMAP", "type": "main", "index": 0 } ] ] }, "Schedule Trigger": { "main": [ [ { "node": "Fetch Gmail via IMAP", "type": "main", "index": 0 } ] ] }, "Fetch Gmail via IMAP": { "main": [ [ { "node": "Parse JSON Output", "type": "main", "index": 0 } ] ] }, "Parse JSON Output": { "main": [ [ { "node": "Build Summary Prompt", "type": "main", "index": 0 } ] ] }, "Build Summary Prompt": { "main": [ [ { "node": "Prepare Structured Prompt", "type": "main", "index": 0 } ] ] }, "Prepare Structured Prompt": { "main": [ [ { "node": "Call Ollama Summary API", "type": "main", "index": 0 } ] ] }, "Call Ollama Summary API": { "main": [ [ { "node": "Parse Ollama Summary", "type": "main", "index": 0 } ] ] }, "Parse Ollama Summary": { "main": [ [ { "node": "Has Urgent Items?", "type": "main", "index": 0 } ] ] }, "Has Urgent Items?": { "main": [ [ { "node": "Normalize Summary Message", "type": "main", "index": 0 } ], [] ] }, "Normalize Summary Message": { "main": [ [ { "node": "Fix Action Table Layout", "type": "main", "index": 0 } ] ] }, "Fix Action Table Layout": { "main": [ [ { "node": "Send Telegram Summary", "type": "main", "index": 0 } ] ] }, "Send Telegram Summary": { "main": [ [ { "node": "Telegram Delivery Failed?", "type": "main", "index": 0 } ] ] }, "Telegram Delivery Failed?": { "main": [ [ { "node": "Send ntfy Summary", "type": "main", "index": 0 } ], [] ] } }, "settings": { "executionOrder": "v1" }, "pinData": {}, "active": false }