Spaces:
Running
Running
Download htmlToText.js from AhmedMostafa-notabot/Support: direct link, hf CLI and curl.
- Browser
- Download file 28.9 kB
-
https://huggingface.co/spaces/AhmedMostafa-notabot/Support/resolve/main/htmlToText.js
- Command line
-
hf download hf://spaces/AhmedMostafa-notabot/Support/htmlToText.js
-
curl -L -o htmlToText.js https://huggingface.co/spaces/AhmedMostafa-notabot/Support/resolve/main/htmlToText.js
28.9 kB
| /** | |
| * Converts HTML email to plain text, conservatively removing signatures | |
| * and quoted reply chains. | |
| * | |
| * Public API: | |
| * htmlEmailToText(html, requesterName?) | |
| * | |
| * Important: | |
| * Email signature extraction is heuristic. Preserve the original HTML | |
| * separately; this output should not be the authoritative ticket record. | |
| */ | |
| const cheerio = require('cheerio'); | |
| const BLOCK_TAGS = new Set([ | |
| 'address', 'article', 'aside', 'blockquote', 'caption', | |
| 'dd', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', | |
| 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', | |
| 'header', 'li', 'main', 'nav', 'ol', 'p', 'pre', | |
| 'section', 'table', 'tr', 'ul', | |
| ]); | |
| // Do not include divtagdefaultwrapper: it can wrap the entire message. | |
| const SIGNATURE_MARKERS = [ | |
| 'signature', | |
| 'gmail_signature', | |
| 'email-signature', | |
| 'mail-signature', | |
| 'x_signature', | |
| 'moz-signature', | |
| ]; | |
| const QUOTE_MARKERS = [ | |
| 'gmail_quote', | |
| 'gmail_quote_container', | |
| 'freshdesk_quote', | |
| 'appendonsend', | |
| // divrplyfwdmsg deliberately excluded: often header-only. | |
| 'yahoo_quoted', | |
| 'protonmail_quote', | |
| 'zmail_extra', | |
| ]; | |
| const ATTRIBUTION_RE = | |
| /^(?:on\b.{3,500}\bwrote|le\b.{3,500}\ba\s*[ée]crit|el\b.{3,500}\bescribi[óo]|am\b.{1,200}\bschrieb\b.{1,300}|il\b.{3,500}\bha\s+scritto|em\b.{3,500}\bescreveu)\s*:\s*$/i; | |
| const ORIGINAL_MESSAGE_RE = | |
| /^[-–—]{2,}\s*(?:original message|forwarded message)\s*[-–—]{2,}\s*$/i; | |
| const FORWARDED_MESSAGE_RE = | |
| /^begin forwarded message\s*:\s*$/i; | |
| const UNDERSCORE_SEPARATOR_RE = /^_{5,}$/; | |
| const HEADER_LINE_RE = | |
| /^(from|sent|date|to|subject|cc|reply-to|de|envoyé|envoye|à|a|objet|para|fecha|asunto|von|gesendet|an|betreff)\s*:\s*(.*)$/i; | |
| const HEADER_NAMES = { | |
| from: 'from', | |
| de: 'from', | |
| von: 'from', | |
| sent: 'date', | |
| date: 'date', | |
| envoyé: 'date', | |
| envoye: 'date', | |
| fecha: 'date', | |
| gesendet: 'date', | |
| to: 'to', | |
| à: 'to', | |
| a: 'to', | |
| para: 'to', | |
| an: 'to', | |
| subject: 'subject', | |
| objet: 'subject', | |
| asunto: 'subject', | |
| betreff: 'subject', | |
| cc: 'cc', | |
| 'reply-to': 'reply-to', | |
| }; | |
| const SIGNOFF_RE = | |
| /^(?:thanks(?:\s+(?:(?:and|&)\s+)?regards)?|thank\s+you(?:\s+so\s+much)?|many\s+thanks|best(?:\s+(?:regards|wishes))?|kind\s+regards|warm\s+regards|regards|sincerely|cheers|yours(?:\s+(?:truly|sincerely))?|with\s+appreciation|cordially|respectfully)[,!.\s]*$/i; | |
| const SIGNATURE_SEPARATOR_RE = /^--\s*$/; | |
| const MOBILE_SIGNATURE_RE = | |
| /^(?:sent from (?:my|a) (?:iphone|ipad|ipod|android|samsung|galaxy|blackberry|mobile|phone)\b.*|sent from (?:outlook|mail) for (?:ios|android|windows)\b.*|get outlook for (?:ios|android)\b.*)$/i; | |
| const DISCLAIMER_RE = | |
| /^(?:confidentiality\s+(?:notice|disclaimer)|(?:legal\s+)?disclaimer\s*:\s*(?:this|the|please|if|confidential|privileged)|privileged\s+(?:and\s+)?confidential|this\s+(?:e-?mail|message|communication)(?:\s+and\s+(?:any\s+)?attachments?)?\s+(?:is|are|may\s+be)\s+(?:confidential|privileged|intended)|the\s+information\s+contained\s+in\s+this\s+(?:e-?mail|message|communication)\s+is\s+(?:confidential|privileged|intended)|if\s+you\s+(?:are\s+not\s+the\s+intended\s+recipient|have\s+received\s+this\s+(?:e-?mail|message)\s+in\s+error)|please\s+consider\s+the\s+environment\s+before\s+printing)/i; | |
| const LEGAL_CONTINUATION_RE = | |
| /(?:confidential|privileged|intended recipient|unauthori[sz]ed|disclos(?:e|ure)|distribution|dissemination|attachments?|received.{0,40}error|delete.{0,40}(?:message|email|e-mail)|notify.{0,40}sender|liability|virus|printing this|all rights reserved|copyright)/i; | |
| // Used to prevent a disclaimer/sign-off from eating an obvious new request. | |
| const REQUEST_LINE_RE = | |
| /^(?:p\.?\s*s\.?(?=\s|[:.]|$)\s*[:.]?|please\b|can you\b|could you\b|would you\b|i (?:need|want|have|am|cannot|can't)\b|we (?:need|want|have|are|cannot|can't)\b|also\b|however\b|but\b|the (?:issue|problem|error)\b|it (?:still|does|doesn't|is|isn't)\b)/i; | |
| const FORM_FIELD_RE = | |
| /^(?:first\s*name|last\s*name|full\s*name|name|e-?mail|organization|organisation|company|products?|timezone|time\s*zone|phone|telephone|mobile|subject|message|comments?|description|department|account(?:\s+(?:id|number))?|ticket(?:\s+(?:id|number))?)\s*[:\t]/i; | |
| const EMAIL_RE = /[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+/i; | |
| const EMAIL_LINE_RE = | |
| /^(?:(?:e-?mail|e)\s*[:|]\s*)?[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+[.,;]?$/i; | |
| const URL_LINE_RE = | |
| /^(?:(?:web(?:site)?|url|linkedin)\s*[:|]\s*)?(?:https?:\/\/|www\.)\S+$/i; | |
| const PHONE_LINE_RE = | |
| /^(?:(?:tel(?:ephone)?|phone|mobile|cell|fax|direct|office|work|t|m|p|f)\s*[:.]?\s*)?(?:\+?\d|\(\d)[\d\s()./-]*(?:\s*(?:(?:internal|int\.?)\s*[:.]?\s*(?:ex\.?|ext\.?)?|ext\.?|extension|ex\.?|x)\s*[:.]?\s*\d+)?$/i; | |
| const TITLE_LINE_RE = | |
| /^(?:(?:senior|junior|principal|staff|lead|chief|vice president|vp|head of|assistant|associate|regional|global|technical|customer success)\s+)?(?:software|systems?|network|support|sales|security|data|product|project|account|marketing|operations?|business|solutions?)?\s*(?:engineer|architect|developer|manager|director|specialist|consultant|coordinator|analyst|administrator|representative|officer|executive|president|founder|owner|ceo|cto|coo|cfo|designer)(?:\s+of\s+[A-Za-z][A-Za-z0-9&/ -]{1,40})?(?:\s*[,–-]\s*[A-Za-z0-9&/.-]{1,30})?$/i; | |
| const CREDENTIALS_LINE_RE = | |
| /^[A-Z][A-Z0-9/&.-]{1,10}(?:\s+[IVX]{1,4})?(?:,\s*[A-Z][A-Z0-9/&.-]{1,10}(?:\s+[IVX]{1,4})?)+$/; | |
| const SIGNATURE_BOOKING_LINE_RE = | |
| /^(?:(?:schedule|book)\s+(?:a\s+)?(?:meeting|time|call|appointment)(?:\s+with\s+me)?\s*:(?:\s*\S.*)?|calendar)$/i; | |
| const SIGNATURE_STATUS_LINE_RE = | |
| /^(?:out\s+of\s+(?:the\s+)?office|ooo|upcoming\s+(?:pto|out\s+of\s+office|leave)|travel(?:l?ing)?(?:\s+for\s+work)?|working\s+hours)\s*:\s*\S.*$/i; | |
| const COMPANY_LINE_RE = | |
| /(?:\b(?:inc|incorporated|llc|l\.?l\.?c|ltd|limited|corp|corporation|gmbh|plc|llp|p\.?c|s\.?a|b\.?v|pty\.?\s*ltd)\b\.?|\b(?:university|college|institute|hospital|foundation)\b)$/i; | |
| // Corporate suffix followed by a short comma clause (contractor / division / client relationship). | |
| // Example: "Infosys Limited, Contracted to Pfizer Business Technology" | |
| const COMPANY_WITH_CLAUSE_RE = | |
| /\b(?:inc\.?|incorporated|llc|l\.?l\.?c\.?|ltd\.?|limited|corp\.?|corporation|gmbh|plc|llp|p\.?c\.?|s\.?a\.?|b\.?v\.?|pty\.?\s*ltd\.?)\b,\s*[\p{L}\d&/().' -]{2,70}$/iu; | |
| // Title-cased org-unit / department line with a unit keyword, optional (ACRONYM). | |
| // Example: "Cloud Transformation, Infrastructure and Enterprise Solutions(C-TIES)" | |
| const ORG_UNIT_KEYWORD_RE = | |
| /\b(?:solutions?|services|technology|technologies|infrastructure|transformation|operations|engineering|department|division|group|team|practice|unit|cent(?:er|re)|office|business)\b/i; | |
| const ADDRESS_RE = | |
| /^(?:\d{1,6}\s+.{1,70}\b(?:street|st|avenue|ave|road|rd|parkway|pkwy|boulevard|blvd|drive|dr|lane|ln|way|court|ct|suite|ste|floor|fl)\b.*|p\.?\s*o\.?\s*box\s+\d+.*|[\p{L} .'-]+,\s*[\p{L} .'-]+\s+\d{5}(?:-\d{4})?(?:[\s,]+(?:USA|U\.S\.A\.?|US|U\.S\.?|United States(?:\s+of\s+America)?|Canada|UK|U\.K\.?))?)$/iu; | |
| const SIGNATURE_LOOKBACK = 15; | |
| function normalizeLine(value) { | |
| return value | |
| .replace(/\u00a0/g, ' ') | |
| .replace(/[\u200b\u200e\u200f\ufeff]/g, '') | |
| .replace(/[^\S\t\r\n]+/g, ' ') | |
| .trim(); | |
| } | |
| function normalizeName(value) { | |
| return normalizeLine(value || '') | |
| .normalize('NFKC') | |
| .toLowerCase() | |
| .replace(/\s+/g, ' '); | |
| } | |
| function attrHitsMarker(value, markers) { | |
| if (!value) return false; | |
| const lower = value.toLowerCase(); | |
| return markers.some(marker => { | |
| const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| return new RegExp( | |
| `(?:^|[\\s_-])${escaped}(?:$|[\\s_-])` | |
| ).test(lower); | |
| }); | |
| } | |
| function hasMarker($el, markers) { | |
| return ( | |
| attrHitsMarker($el.attr('class'), markers) || | |
| attrHitsMarker($el.attr('id'), markers) | |
| ); | |
| } | |
| function isAttribution(text) { | |
| return ATTRIBUTION_RE.test(text); | |
| } | |
| const DATE_OR_TIME_EVIDENCE_RE = | |
| /(?:\b(?:mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?)\b|\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b|\b\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?\b|\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\b|\b\d{1,2}:\d{2}(?:\s*[ap]\.?m\.?)?\b)/i; | |
| function isStrongAttribution(text) { | |
| if (!isAttribution(text)) return false; | |
| return ( | |
| EMAIL_RE.test(text) || | |
| DATE_OR_TIME_EVIDENCE_RE.test(text) | |
| ); | |
| } | |
| function isExplicitTextBoundary(text) { | |
| return ( | |
| ORIGINAL_MESSAGE_RE.test(text) || | |
| FORWARDED_MESSAGE_RE.test(text) || | |
| isStrongAttribution(text) | |
| ); | |
| } | |
| /** | |
| * Plain-text rendering used to inspect a possible signature card. | |
| * Includes the root node and handles text nodes, blocks, and <br>. | |
| */ | |
| function visibleText(node) { | |
| if (!node) return ''; | |
| if (node.type === 'text') return node.data || ''; | |
| const tag = (node.tagName || '').toLowerCase(); | |
| if (tag === 'script' || tag === 'style') return ''; | |
| if (tag === 'br') return '\n'; | |
| const text = (node.children || []).map(visibleText).join(''); | |
| if (tag === 'td' || tag === 'th') return `${text}\t`; | |
| return BLOCK_TAGS.has(tag) ? `\n${text}\n` : text; | |
| } | |
| function isPhoneLine(line) { | |
| if (!PHONE_LINE_RE.test(line)) return false; | |
| const digits = line.replace(/\D/g, ''); | |
| return digits.length >= 7 && digits.length <= 18; | |
| } | |
| function isContactLine(line) { | |
| return EMAIL_LINE_RE.test(line) || URL_LINE_RE.test(line) || isPhoneLine(line); | |
| } | |
| function looksLikeName(line, requesterName) { | |
| if (requesterName && normalizeName(line) === normalizeName(requesterName)) { | |
| return true; | |
| } | |
| if (line.length > 70 || REQUEST_LINE_RE.test(line)) return false; | |
| if (isTitleLine(line) || isCompanyLine(line)) return false; | |
| // Deliberately conservative without a known sender name. | |
| // Lowercase/non-Latin display names still work through requesterName. | |
| return /^(?:\p{Lu}[\p{L}'’.-]*)(?:\s+(?:(?:de|del|van|von|da|di|la)\s+)?\p{Lu}[\p{L}'’.-]*){1,3}$/u.test(line); | |
| } | |
| function isTitleLine(line) { | |
| return ( | |
| line.length <= 90 && | |
| !/[?!]/.test(line) && | |
| !/[.]\s*$/.test(line) && | |
| !REQUEST_LINE_RE.test(line) && | |
| !/\b(?:i(?!\.)|we|you|your|our|need|please|escalate|send|contact|call|update|fix|change)\b/i.test(line) && | |
| TITLE_LINE_RE.test(line) | |
| ); | |
| } | |
| function isCompanyLine(line) { | |
| return ( | |
| line.length <= 90 && | |
| !/[?!]/.test(line) && | |
| !REQUEST_LINE_RE.test(line) && | |
| !/\b(?:i(?!\.)|we|you|your|our|need|please|escalate|send|contact|call|update|fix|change)\b/i.test(line) && | |
| (COMPANY_LINE_RE.test(line) || COMPANY_WITH_CLAUSE_RE.test(line)) | |
| ); | |
| } | |
| function isOrgUnitLine(line) { | |
| if (line.length > 90 || /[?!]/.test(line) || /[.]\s*$/.test(line)) return false; | |
| if (REQUEST_LINE_RE.test(line)) return false; | |
| if (/\b(?:i(?!\.)|we|you|your|our|need|please|escalate|send|contact|call|update|fix|change|is|are|was|has|have|will)\b/i.test(line)) return false; | |
| if (!ORG_UNIT_KEYWORD_RE.test(line)) return false; | |
| // Strip an optional trailing "(C-TIES)" acronym, then require Title Case | |
| // words (small connectors like "and"/"of"/"for" allowed). | |
| const body = line.replace(/\s*\([A-Z][A-Z0-9&/-]{1,12}\)\s*$/, ''); | |
| const words = body.split(/[\s,/&-]+/).filter(Boolean); | |
| return words.length >= 2 && words.every(w => | |
| /^(?:and|of|for|the|to|in|on)$/i.test(w) || | |
| /^\p{Lu}[\p{L}\d'’.-]*$/u.test(w) | |
| ); | |
| } | |
| function isSignatureDetail(line, requesterName) { | |
| return ( | |
| looksLikeName(line, requesterName) || | |
| isTitleLine(line) || | |
| isCompanyLine(line) || | |
| isOrgUnitLine(line) || | |
| isContactLine(line) || | |
| ADDRESS_RE.test(line) || | |
| MOBILE_SIGNATURE_RE.test(line) || | |
| SIGNATURE_SEPARATOR_RE.test(line) || | |
| SIGNOFF_RE.test(line) || | |
| CREDENTIALS_LINE_RE.test(line) || | |
| SIGNATURE_BOOKING_LINE_RE.test(line) || | |
| SIGNATURE_STATUS_LINE_RE.test(line) | |
| ); | |
| } | |
| // Examples: | |
| // DPC, Donnelly 115 | |
| // MIT, Building 32 | |
| // | |
| // Deliberately requires an uppercase campus/site code, a comma, | |
| // a capitalized building name, and a room/building number. | |
| const CAMPUS_LOCATION_RE = | |
| /^[A-Z][A-Z0-9&.-]{1,11},\s*(?:\p{Lu}[\p{L}.'’-]*\s+){1,4}\d{1,5}[A-Za-z]?$/u; | |
| // Examples: | |
| // Room 115 | |
| // Office: B-204 | |
| // Suite 300 | |
| // Building 32 | |
| // | |
| // Does not accept arbitrary text after the room identifier. | |
| const LABELED_ROOM_RE = | |
| /^(?:room|rm\.?|office|suite|ste\.?|building|bldg\.?)\s*:?\s*(?:[A-Za-z]{1,3}[- ]?)?\d{1,5}[A-Za-z]?(?:[-/]\d{1,5}[A-Za-z]?)?$/i; | |
| function isSignatureLocationLine(line) { | |
| return ( | |
| line.length <= 80 && | |
| !REQUEST_LINE_RE.test(line) && | |
| ( | |
| CAMPUS_LOCATION_RE.test(line) || | |
| LABELED_ROOM_RE.test(line) | |
| ) | |
| ); | |
| } | |
| /** | |
| * Validate a complete signature tail. | |
| * | |
| * A building/room line is allowed only when: | |
| * - the tail contains a recognizable contact line; | |
| * - it also contains a title or company line; | |
| * - at most one line needs the location exception. | |
| * | |
| * Every other line must satisfy the existing signature-detail rules. | |
| * Unknown prose is never silently accepted. | |
| */ | |
| function isSignatureTail(lines, requesterName) { | |
| if (hasStructuredForm(lines)) return false; | |
| const hasContact = lines.some(isContactLine); | |
| const hasProfessionalContext = lines.some(line => | |
| isTitleLine(line) || isCompanyLine(line) || isOrgUnitLine(line) | |
| ); | |
| let locationExceptions = 0; | |
| for (const line of lines) { | |
| if (isSignatureDetail(line, requesterName)) continue; | |
| if ( | |
| hasContact && | |
| hasProfessionalContext && | |
| locationExceptions === 0 && | |
| isSignatureLocationLine(line) | |
| ) { | |
| locationExceptions++; | |
| continue; | |
| } | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * A logo + website alone is not enough: it could be a real message card. | |
| * Require a compact contact card with a person/title signal and a direct | |
| * contact link, and never classify a leading layout table as a signature. | |
| */ | |
| function isSignatureCard($, el, requesterName, hasPrecedingText) { | |
| if (!hasPrecedingText) return false; | |
| const tag = (el.tagName || '').toLowerCase(); | |
| if (!['table', 'div', 'section'].includes(tag)) return false; | |
| const $el = $(el); | |
| if (!$el.find('img').length) return false; | |
| const hasDirectContact = $el.find('a[href]').toArray().some(a => | |
| /^(?:mailto|tel):/i.test(($(a).attr('href') || '').trim()) | |
| ); | |
| const lines = visibleText(el) | |
| .split(/\r\n?|\n|\t/) | |
| .map(normalizeLine) | |
| .filter(Boolean); | |
| const hasVisibleContact = lines.some(isContactLine); | |
| if (!hasDirectContact && !hasVisibleContact) return false; | |
| if (lines.length < 2 || lines.length > 8) return false; | |
| if (lines.join(' ').length > 500) return false; | |
| return ( | |
| lines.some(line => | |
| looksLikeName(line, requesterName) || isTitleLine(line) | |
| ) && | |
| isSignatureTail(lines, requesterName) | |
| ); | |
| } | |
| /** | |
| * Render into structured lines rather than injecting magic text tokens. | |
| * | |
| * A removed quote remains represented as { kind: 'quote' }, allowing: | |
| * | |
| * On ... wrote: | |
| * <blockquote type="cite">old message</blockquote> | |
| * My response below the quote. | |
| * | |
| * to retain the response rather than truncating at the attribution. | |
| */ | |
| function renderEmailLines($, root, requesterName, options = {}) { | |
| const keepQuotes = options.keepQuotes === true; | |
| const keepSignatures = options.keepSignatures === true; | |
| const lines = []; | |
| let buffer = ''; | |
| let hasPrecedingText = false; | |
| function flush() { | |
| for (const part of buffer.split(/\r\n?|\n/)) { | |
| const text = normalizeLine(part); | |
| if (!text) continue; | |
| lines.push({ kind: 'text', text }); | |
| hasPrecedingText = true; | |
| } | |
| buffer = ''; | |
| } | |
| function marker(kind) { | |
| flush(); | |
| lines.push({ kind }); | |
| } | |
| function walk(node) { | |
| if (node.type === 'text') { | |
| buffer += node.data || ''; | |
| return; | |
| } | |
| const tag = (node.tagName || '').toLowerCase(); | |
| // Also traverse document/root nodes. | |
| if (!tag) { | |
| (node.children || []).forEach(walk); | |
| return; | |
| } | |
| if (['script', 'style', 'head', 'template'].includes(tag)) return; | |
| const $el = $(node); | |
| const style = $el.attr('style') || ''; | |
| if ( | |
| $el.attr('hidden') !== undefined || | |
| /(?:^|;)\s*display\s*:\s*none\b/i.test(style) || | |
| /(?:^|;)\s*visibility\s*:\s*hidden\b/i.test(style) | |
| ) { | |
| return; | |
| } | |
| if ($el.attr('data-skip-ai-process') !== undefined) { | |
| marker('skipped'); | |
| return; | |
| } | |
| // Document wrappers must not be classified as signatures/quotes. | |
| if (!['html', 'body'].includes(tag)) { | |
| if (!keepSignatures && hasMarker($el, SIGNATURE_MARKERS)) { | |
| marker('signature'); | |
| return; | |
| } | |
| let lastText = normalizeLine(buffer); | |
| if (!lastText) { | |
| for (let li = lines.length - 1; li >= 0; li--) { | |
| if (lines[li].kind === 'text') { | |
| lastText = lines[li].text; | |
| break; | |
| } | |
| } | |
| } | |
| const citePrecededByAttribution = | |
| tag === 'blockquote' && | |
| ($el.attr('type') || '').toLowerCase() === 'cite' && | |
| isStrongAttribution(lastText); | |
| const explicitQuote = | |
| !keepQuotes && | |
| ( | |
| hasMarker($el, QUOTE_MARKERS) || | |
| citePrecededByAttribution | |
| ); | |
| if (explicitQuote) { | |
| marker('quote'); | |
| return; | |
| } | |
| if ( | |
| !keepSignatures && | |
| isSignatureCard( | |
| $, node, requesterName, | |
| hasPrecedingText || Boolean(normalizeLine(buffer)) | |
| ) | |
| ) { | |
| marker('signature'); | |
| return; | |
| } | |
| } | |
| if (tag === 'hr') { | |
| marker('divider'); | |
| return; | |
| } | |
| if (tag === 'br') { | |
| flush(); | |
| return; | |
| } | |
| const block = BLOCK_TAGS.has(tag); | |
| if (block) flush(); | |
| (node.children || []).forEach(walk); | |
| if (tag === 'td' || tag === 'th') { | |
| buffer += '\t'; | |
| } else if (block) { | |
| flush(); | |
| } | |
| } | |
| walk(root); | |
| flush(); | |
| return lines; | |
| } | |
| /** | |
| * Recognize a contiguous reply-header cluster. | |
| * | |
| * Unlike collecting arbitrary headers from the next eight lines, this | |
| * stops at ordinary prose. "From:" in one paragraph and "Subject:" much | |
| * later cannot combine into a false reply boundary. | |
| */ | |
| function readReplyHeader(lines, start) { | |
| const fields = new Map(); | |
| let end = start; | |
| while (end < lines.length && end - start < 8) { | |
| const entry = lines[end]; | |
| if (entry.kind !== 'text') break; | |
| const match = entry.text.match(HEADER_LINE_RE); | |
| if (!match) break; | |
| const name = HEADER_NAMES[match[1].toLowerCase()]; | |
| fields.set(name, match[2].trim()); | |
| end++; | |
| } | |
| const sender = fields.get('from'); | |
| if (!sender) return null; | |
| const hasDate = fields.has('date'); | |
| const hasEmailSender = EMAIL_RE.test(sender); | |
| const confident = | |
| ( | |
| fields.size >= 2 && | |
| hasDate && | |
| (hasEmailSender || fields.size >= 3) | |
| ) || | |
| ( | |
| fields.size >= 3 && | |
| hasEmailSender && | |
| fields.has('subject') | |
| ); | |
| return confident ? { end } : null; | |
| } | |
| function nextRelevantIndex(lines, start) { | |
| let i = start; | |
| while ( | |
| i < lines.length && | |
| ['divider', 'skipped', 'signature'].includes(lines[i].kind) | |
| ) { | |
| i++; | |
| } | |
| return i; | |
| } | |
| /** | |
| * Remove explicit quote nodes and detect unmarked quoted tails. | |
| * | |
| * Explicit >-prefixed quotes can be interleaved with customer answers. | |
| * Do not truncate those answers merely because an attribution precedes | |
| * the first quoted line. | |
| */ | |
| function removeReplyNoise(entries) { | |
| const kept = []; | |
| for (let i = 0; i < entries.length; i++) { | |
| const entry = entries[i]; | |
| if (entry.kind !== 'text') continue; | |
| const text = entry.text; | |
| if (/^>/.test(text)) continue; | |
| const header = readReplyHeader(entries, i); | |
| const attribution = isAttribution(text); | |
| const boundary = isExplicitTextBoundary(text); | |
| const underscoreHeader = | |
| UNDERSCORE_SEPARATOR_RE.test(text) && | |
| readReplyHeader(entries, nextRelevantIndex(entries, i + 1)); | |
| if (!header && !attribution && !boundary && !underscoreHeader) { | |
| kept.push(text); | |
| continue; | |
| } | |
| const after = header ? header.end : i + 1; | |
| const next = nextRelevantIndex(entries, after); | |
| const nextEntry = entries[next]; | |
| const followedByExplicitQuote = | |
| nextEntry?.kind === 'quote' || | |
| ( | |
| nextEntry?.kind === 'text' && | |
| /^>/.test(nextEntry.text) | |
| ); | |
| if (followedByExplicitQuote) { | |
| // Remove only the attribution/header, not later answers. | |
| i = after - 1; | |
| continue; | |
| } | |
| if (header || boundary || underscoreHeader) { | |
| if (kept.length > 0) break; | |
| // A forward/quote at the beginning may be the ticket's entire | |
| // submitted content. Preserve its body rather than returning | |
| // an empty ticket; omit only the recognized header. | |
| i = after - 1; | |
| continue; | |
| } | |
| // Attribution-like prose without corroboration stays intact. | |
| kept.push(text); | |
| } | |
| return kept; | |
| } | |
| // Contact labels frequently occur in real signatures. They do not, by | |
| // themselves, prove that a block is a structured intake submission. | |
| const CONTACT_FIELD_LABEL_RE = | |
| /^(?:e-?mail|phone|telephone|mobile)\s*[:\t]/i; | |
| // These fields strongly suggest submitted ticket/form data. | |
| // Preserve them even when there is only one such label in the candidate. | |
| const INTAKE_ONLY_FIELD_RE = | |
| /^(?:first\s*name|last\s*name|products?|timezone|time\s*zone)\s*[:\t]/i; | |
| function hasStructuredForm(lines) { | |
| if (lines.some(line => INTAKE_ONLY_FIELD_RE.test(line))) { | |
| return true; | |
| } | |
| const fields = lines.filter(line => FORM_FIELD_RE.test(line)); | |
| // Email + Phone can be signature details. | |
| // Organization + Email, Name + Phone, etc. remain protected. | |
| return ( | |
| fields.length >= 2 && | |
| fields.some(line => !CONTACT_FIELD_LABEL_RE.test(line)) | |
| ); | |
| } | |
| // Hard-wrapped disclaimer text (a signature block or pasted legal footer | |
| // wrapped at a fixed column, using literal newlines instead of <br>) can | |
| // split a trigger phrase across two physical lines, e.g.: | |
| // "...if you have received this e-mail in" | |
| // "error please notify the sender..." | |
| // Joining a line with the one right after it lets the phrase regexes match | |
| // across that wrap point. REQUEST_LINE_RE is still tested against the | |
| // unjoined line only, so a genuine new paragraph still breaks the boundary. | |
| function withNextLine(lines, i) { | |
| return i + 1 < lines.length ? `${lines[i]} ${lines[i + 1]}` : lines[i]; | |
| } | |
| function findTrailingDisclaimerBoundary(lines, end) { | |
| for (let i = Math.max(1, end - 20); i < end; i++) { | |
| const anchor = lines[i]; | |
| const anchorJoined = withNextLine(lines, i); | |
| if (!DISCLAIMER_RE.test(anchor) && !DISCLAIMER_RE.test(anchorJoined)) continue; | |
| const rest = lines.slice(i + 1, end); | |
| // Conservative: an unrelated paragraph after the notice makes | |
| // its extent ambiguous, so do not delete the whole tail. | |
| if (rest.every((line, j) => { | |
| const joined = withNextLine(rest, j); | |
| return ( | |
| DISCLAIMER_RE.test(line) || | |
| DISCLAIMER_RE.test(joined) || | |
| ( | |
| !REQUEST_LINE_RE.test(line) && | |
| ( | |
| LEGAL_CONTINUATION_RE.test(line) || | |
| LEGAL_CONTINUATION_RE.test(joined) || | |
| isContactLine(line) || | |
| /^©|^copyright\b/i.test(line) | |
| ) | |
| ) | |
| ); | |
| })) { | |
| return i; | |
| } | |
| } | |
| return -1; | |
| } | |
| function findSignatureBoundary(lines, end, requesterName) { | |
| const floor = Math.max(1, end - SIGNATURE_LOOKBACK); | |
| for (let i = floor; i < end; i++) { | |
| const line = lines[i]; | |
| const isBoundary = | |
| SIGNOFF_RE.test(line) || | |
| SIGNATURE_SEPARATOR_RE.test(line) || | |
| MOBILE_SIGNATURE_RE.test(line); | |
| if (!isBoundary) continue; | |
| const tail = lines.slice(i + 1, end); | |
| if (isSignatureTail(tail, requesterName)) { | |
| return i; | |
| } | |
| } | |
| return -1; | |
| } | |
| function findUnmarkedSignatureBoundary(lines, end, requesterName) { | |
| const floor = Math.max(1, end - SIGNATURE_LOOKBACK); | |
| const window = lines.slice(floor, end); | |
| // Protect structured submitted data before considering any cut point. | |
| if (hasStructuredForm(window)) return -1; | |
| for (let i = floor; i < end - 1; i++) { | |
| const name = lines[i]; | |
| if (!looksLikeName(name, requesterName)) continue; | |
| const tail = lines.slice(i + 1, end); | |
| if (!tail.length) continue; | |
| if (!isSignatureTail(tail, requesterName)) continue; | |
| const knownName = Boolean( | |
| requesterName && | |
| normalizeName(name) === normalizeName(requesterName) | |
| ); | |
| const hasContact = tail.some(isContactLine); | |
| const hasTitle = tail.some(isTitleLine); | |
| const hasCompany = tail.some(isCompanyLine); | |
| if ( | |
| (knownName && (hasContact || hasTitle)) || | |
| (hasContact && (hasTitle || hasCompany)) | |
| ) { | |
| let cut = i; | |
| while (cut > 1 && SIGNATURE_STATUS_LINE_RE.test(lines[cut - 1])) { | |
| cut--; | |
| } | |
| return cut; | |
| } | |
| } | |
| return -1; | |
| } | |
| function stripTrailingEmailNoise(lines, requesterName) { | |
| if (!lines.length) return ''; | |
| let end = lines.length; | |
| const disclaimer = findTrailingDisclaimerBoundary(lines, end); | |
| if (disclaimer >= 0) end = disclaimer; | |
| let signature = findSignatureBoundary(lines, end, requesterName); | |
| if (signature < 0) { | |
| signature = findUnmarkedSignatureBoundary(lines, end, requesterName); | |
| } | |
| if (signature >= 0) end = signature; | |
| return lines.slice(0, end).join('\n'); | |
| } | |
| /** | |
| * @param {string} html | |
| * @param {string} [requesterName] | |
| * @returns {string} | |
| */ | |
| function htmlEmailToText(html, requesterName) { | |
| if (html == null || html === '') return ''; | |
| // Fail explicitly on unexpected API payloads instead of converting an | |
| // object to "[object Object]" or silently producing an empty message. | |
| if (typeof html !== 'string') { | |
| throw new TypeError('htmlEmailToText: html must be a string'); | |
| } | |
| const name = typeof requesterName === 'string' ? requesterName : undefined; | |
| const $ = cheerio.load(html); | |
| const root = $('body').get(0) || $.root().get(0); | |
| const entries = renderEmailLines($, root, name); | |
| const lines = removeReplyNoise(entries); | |
| const cleaned = stripTrailingEmailNoise(lines, name); | |
| if (cleaned) return cleaned; | |
| // Never let the heuristics turn a non-empty message into an empty one. | |
| // Re-render with quote/signature containers kept. Hidden, | |
| // display:none, and data-skip-ai-process content stays excluded, | |
| // because those checks run above the options gates. | |
| const fallbackEntries = renderEmailLines($, root, name, { | |
| keepQuotes: true, | |
| keepSignatures: true, | |
| }); | |
| const fallbackLines = removeReplyNoise(fallbackEntries); | |
| const fallbackText = stripTrailingEmailNoise(fallbackLines, name); | |
| if (fallbackText) return fallbackText; | |
| // Last resort: return everything textual rather than an empty ticket. | |
| return fallbackEntries | |
| .filter(entry => entry.kind === 'text') | |
| .map(entry => entry.text) | |
| .filter(Boolean) | |
| .join('\n'); | |
| } | |
| module.exports = { htmlEmailToText }; |