@open-mercato/core 0.6.5-develop.5162.1.eba42159b8 → 0.6.5-develop.5169.1.d0671533ca

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,9 +46,19 @@ function decodeBasicEntities(input) {
46
46
  (match) => BASIC_HTML_ENTITIES[match.toLowerCase()] ?? match
47
47
  );
48
48
  }
49
+ function stripRemainingTags(html) {
50
+ let previous;
51
+ let current = html;
52
+ do {
53
+ previous = current;
54
+ current = current.replace(/<[^>]*>/g, "");
55
+ } while (current !== previous);
56
+ return current;
57
+ }
49
58
  function htmlToText(html) {
50
- const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(html), "style"), "script").replace(/<br\s*\/?>(?=\s*)/gi, "\n").replace(/<\/p\s*>/gi, "\n\n");
51
- return decodeBasicEntities(stripped).replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").trim();
59
+ const decoded = decodeBasicEntities(html);
60
+ const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(decoded), "style"), "script").replace(/<br\s*\/?>(?=\s*)/gi, "\n").replace(/<\/p\s*>/gi, "\n\n");
61
+ return stripRemainingTags(stripped).replace(/\n{3,}/g, "\n\n").trim();
52
62
  }
53
63
  function escapeQuotes(value) {
54
64
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/email-mime.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport type { NormalizedInboundMessage, NormalizedAttachment } from './adapter'\nimport { EMAIL_MAX_ATTACHMENT_BYTES } from './email-capabilities'\n\n/**\n * Aggregate ceiling for all attachments on a single inbound message. Inbound\n * mail is untrusted, so without a cap a malicious/large message would be fully\n * base64-buffered in memory (~1.33x raw bytes) and persisted. Allow a small\n * multiple of the per-attachment limit for legitimate multi-file emails.\n */\nconst TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES = EMAIL_MAX_ATTACHMENT_BYTES * 2\n\n/**\n * Shared email MIME helpers for the email channel providers (Gmail, IMAP).\n * Outbound assembly, inbound parsing, header/address normalization,\n * and threading-id extraction all live here so every provider shares one\n * correct implementation instead of copy-pasting (which previously let Gmail's\n * `extractHeaders` drift into a Map-handling bug that IMAP had already fixed).\n *\n * Provider-specific transport (Gmail History API, IMAP UID sync)\n * stays in each package \u2014 this module only owns the format-level plumbing.\n */\n\n// \u2500\u2500 Outbound MIME helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport function stringOrUndefined(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function toAddressList(value: unknown): string[] {\n if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter((v) => v.length > 0)\n if (typeof value === 'string') {\n return value\n .split(/[,;]\\s*/)\n .map((v) => v.trim())\n .filter((v) => v.length > 0)\n }\n return []\n}\n\nexport function referencesFromMeta(value: unknown): string[] | undefined {\n if (!Array.isArray(value)) return undefined\n return value.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Strip every `<tag>\u2026</tag>` block (and its contents) for a single tag name.\n * Inbound HTML is untrusted, so the matcher must resist evasion: the close tag\n * allows trailing attributes/whitespace (`</script >`, `</style foo>`), the `i`\n * flag covers mixed case, and the replacement loops until the string is stable\n * so a payload split across nested or reconstructed tags cannot survive a\n * single pass (`<scr<script>ipt>` collapsing back into `<script>`).\n *\n * The opening tag is also matched when it is truncated or never closed \u2014 a bare\n * `<script` or an unterminated `<script>\u2026` running to end-of-input is removed\n * outright \u2014 so no prefix of the element name can leak into the output.\n */\nfunction stripTagBlocks(html: string, tag: string): string {\n const blockPattern = new RegExp(\n `<${tag}\\\\b[^>]*(?:>[\\\\s\\\\S]*?(?:<\\\\/${tag}[^>]*>|$)|$)`,\n 'gi',\n )\n let previous: string\n let current = html\n do {\n previous = current\n current = current.replace(blockPattern, ' ')\n } while (current !== previous)\n return current\n}\n\n/**\n * Drop HTML comments (`<!-- \u2026 -->`), including an unterminated comment running\n * to end-of-input. Comments are stripped first because they can wrap content\n * that would otherwise survive tag removal (`<!--<script-->`), and a naive\n * filter that ignores them leaves a tag fragment behind.\n */\nfunction stripHtmlComments(html: string): string {\n return html.replace(/<!--[\\s\\S]*?(?:-->|$)/g, ' ')\n}\n\nconst BASIC_HTML_ENTITIES: Record<string, string> = {\n '&nbsp;': ' ',\n '&lt;': '<',\n '&gt;': '>',\n '&quot;': '\"',\n '&amp;': '&',\n}\n\n/**\n * Decode the handful of HTML entities we surface in plaintext in ONE\n * left-to-right pass. A single pass cannot double-unescape: characters produced\n * by a replacement (e.g. the `&` from `&amp;`) are never re-scanned, so\n * `&amp;lt;` decodes to the literal `&lt;` rather than collapsing into `<`.\n */\nfunction decodeBasicEntities(input: string): string {\n return input.replace(\n /&(?:nbsp|lt|gt|quot|amp);/gi,\n (match) => BASIC_HTML_ENTITIES[match.toLowerCase()] ?? match,\n )\n}\n\nexport function htmlToText(html: string): string {\n const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(html), 'style'), 'script')\n .replace(/<br\\s*\\/?>(?=\\s*)/gi, '\\n')\n .replace(/<\\/p\\s*>/gi, '\\n\\n')\n return decodeBasicEntities(stripped)\n .replace(/<[^>]+>/g, '')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\nexport function escapeQuotes(value: string): string {\n return value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')\n}\n\n/**\n * Collapse CR/LF/TAB in an email header value to a single space to prevent\n * RFC 5322 header injection \u2014 e.g. a Subject smuggling an extra\n * `Bcc:`/`Content-Type:` header or splitting the message into a body.\n * Collapsing (rather than folding) is safe for the short structured headers\n * we emit (Subject, addresses, Message-ID, References).\n */\nexport function sanitizeHeaderValue(value: string): string {\n return value.replace(/[\\r\\n\\t]+/g, ' ').trim()\n}\n\nexport function ensureBrackets(value: string): string {\n const trimmed = sanitizeHeaderValue(value)\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed\n return `<${trimmed}>`\n}\n\nfunction isPureAscii(value: string): boolean {\n // eslint-disable-next-line no-control-regex\n return /^[\\x00-\\x7F]*$/.test(value)\n}\n\n/**\n * Encode a single header value as an RFC 2047 \"B\" (base64) encoded-word when it\n * contains non-ASCII characters, so 8-bit text like \"Caf\u00E9\" survives strict MTAs\n * that treat header bytes as 7-bit ASCII. Pure-ASCII values are returned\n * unchanged. Apply only AFTER `sanitizeHeaderValue` so the CR/LF injection guard\n * still runs against the raw value.\n */\nexport function encodeHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n return `=?utf-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`\n}\n\n/**\n * Encode the display-name part of a single address header value, leaving the\n * `<addr@domain>` untouched (per RFC 2047, encoded-words are not permitted\n * inside the addr-spec). Inputs without a bracketed address are treated as a\n * bare display name / address and encoded only when non-ASCII.\n */\nexport function encodeAddressHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n const match = value.match(/^(.*?)(\\s*<[^>]*>)\\s*$/)\n if (match) {\n const [, displayPart, addrPart] = match\n const displayName = displayPart.replace(/^\"|\"$/g, '').trim()\n if (!displayName) return value\n return `${encodeHeaderWord(displayName)}${addrPart}`\n }\n return encodeHeaderWord(value)\n}\n\n/**\n * Generate an RFC 5322 Message-ID rooted in the sender's domain. Used as a\n * downstream idempotency key, so entropy comes from `crypto.randomUUID()`\n * rather than `Math.random()`.\n */\nexport function generateMessageId(fromAddress: string, fallbackDomain = 'localhost'): string {\n const domain = fromAddress.split('@')[1] ?? fallbackDomain\n return `<${crypto.randomUUID()}@${domain}>`\n}\n\nexport interface AssembleRfc2822Input {\n from: string\n to: string[]\n cc: string[]\n bcc: string[]\n subject: string | undefined\n text: string | undefined\n html: string | undefined\n inReplyTo: string | undefined\n references: string[] | undefined\n messageId: string\n}\n\n/**\n * Render one MIME body part's CTE header + body content. Non-ASCII bodies are\n * base64-encoded (CRLF-wrapped at 76 cols) and labelled\n * `Content-Transfer-Encoding: base64` so 8-bit text survives strict MTAs;\n * pure-ASCII bodies stay `7bit` and verbatim.\n */\nfunction encodeBodyPart(content: string): { cte: string; body: string } {\n if (isPureAscii(content)) return { cte: '7bit', body: content }\n const base64 = Buffer.from(content, 'utf-8').toString('base64')\n const wrapped = base64.match(/.{1,76}/g)?.join('\\r\\n') ?? base64\n return { cte: 'base64', body: wrapped }\n}\n\n/**\n * Assemble a raw RFC2822 message (used by transports that send the encoded\n * message directly, e.g. Gmail `users.messages.send`). Emits a\n * `multipart/alternative` body when both html and text are present, otherwise a\n * single-part text or html body.\n */\nexport function assembleRfc2822(input: AssembleRfc2822Input): Buffer {\n const boundary = `omc_${crypto.randomUUID()}`\n const headers: string[] = []\n headers.push(`From: ${encodeAddressHeaderWord(sanitizeHeaderValue(input.from))}`)\n headers.push(`To: ${input.to.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.cc.length) headers.push(`Cc: ${input.cc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.bcc.length) headers.push(`Bcc: ${input.bcc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.subject) headers.push(`Subject: ${encodeHeaderWord(sanitizeHeaderValue(input.subject))}`)\n headers.push(`Message-ID: ${ensureBrackets(input.messageId)}`)\n if (input.inReplyTo) headers.push(`In-Reply-To: ${ensureBrackets(input.inReplyTo)}`)\n if (input.references && input.references.length) {\n headers.push(`References: ${input.references.map(ensureBrackets).join(' ')}`)\n }\n headers.push('MIME-Version: 1.0')\n headers.push(`Date: ${new Date().toUTCString()}`)\n\n if (input.html && input.text) {\n headers.push(`Content-Type: multipart/alternative; boundary=\"${boundary}\"`)\n const textPart = encodeBodyPart(input.text)\n const htmlPart = encodeBodyPart(input.html)\n const body = [\n '',\n `--${boundary}`,\n 'Content-Type: text/plain; charset=utf-8',\n `Content-Transfer-Encoding: ${textPart.cte}`,\n '',\n textPart.body,\n `--${boundary}`,\n 'Content-Type: text/html; charset=utf-8',\n `Content-Transfer-Encoding: ${htmlPart.cte}`,\n '',\n htmlPart.body,\n `--${boundary}--`,\n '',\n ].join('\\r\\n')\n return Buffer.from(headers.join('\\r\\n') + body, 'utf-8')\n }\n\n if (input.html) {\n const htmlPart = encodeBodyPart(input.html)\n headers.push('Content-Type: text/html; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${htmlPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + htmlPart.body, 'utf-8')\n }\n\n const textPart = encodeBodyPart(input.text ?? '')\n headers.push('Content-Type: text/plain; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${textPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + textPart.body, 'utf-8')\n}\n\n// \u2500\u2500 Inbound MIME parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface ParsedMail {\n messageId?: string | null\n inReplyTo?: string | null\n references?: string | string[] | null\n from?: { value?: Array<{ address?: string; name?: string }> }\n to?: { value?: Array<{ address?: string; name?: string }> }\n cc?: { value?: Array<{ address?: string; name?: string }> }\n bcc?: { value?: Array<{ address?: string; name?: string }> }\n subject?: string | null\n html?: string | false\n text?: string\n date?: string | Date | null\n attachments?: ParsedAttachment[]\n headers?: Map<string, unknown> | Record<string, unknown>\n}\n\nexport interface ParsedAttachment {\n content?: Buffer | Uint8Array\n contentType?: string\n filename?: string\n size?: number\n contentDisposition?: string\n cid?: string\n}\n\nexport function stripBrackets(value: string | undefined | null): string | undefined {\n if (!value) return undefined\n const trimmed = value.trim()\n if (!trimmed) return undefined\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1)\n return trimmed\n}\n\nexport function parseReferences(value: string | string[] | undefined | null): string[] {\n if (!value) return []\n if (Array.isArray(value)) return value.map((v) => stripBrackets(v)).filter((v): v is string => Boolean(v))\n return value\n .split(/\\s+/)\n .map((segment) => stripBrackets(segment))\n .filter((segment): segment is string => Boolean(segment))\n}\n\nexport function normalizeAttachments(attachments: ParsedAttachment[]): NormalizedAttachment[] {\n const out: NormalizedAttachment[] = []\n let totalBytes = 0\n for (const att of attachments) {\n if (!att.content) continue\n const byteLength = att.content.byteLength\n if (byteLength > EMAIL_MAX_ATTACHMENT_BYTES) {\n console.warn(\n `[email-mime] dropping oversized inbound attachment \"${att.filename ?? 'attachment'}\" (${byteLength} bytes > ${EMAIL_MAX_ATTACHMENT_BYTES} cap)`,\n )\n continue\n }\n if (totalBytes + byteLength > TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES) {\n console.warn(\n `[email-mime] aggregate inbound attachment size exceeded ${TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES} bytes; dropping remaining attachments`,\n )\n break\n }\n totalBytes += byteLength\n const base64 = Buffer.isBuffer(att.content)\n ? att.content.toString('base64')\n : Buffer.from(att.content).toString('base64')\n out.push({\n url: `data:${att.contentType ?? 'application/octet-stream'};base64,${base64}`,\n mimeType: att.contentType ?? 'application/octet-stream',\n fileName: att.filename ?? 'attachment',\n fileSize: att.size,\n inline: Boolean(att.contentDisposition && /inline/i.test(att.contentDisposition)) || Boolean(att.cid),\n })\n }\n return out\n}\n\nfunction stringifyHeaderValue(value: unknown): string | undefined {\n if (typeof value === 'string') return value\n if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')\n if (value instanceof Date) return value.toISOString()\n if (value && typeof value === 'object') {\n try {\n return JSON.stringify(value)\n } catch {\n return undefined\n }\n }\n if (value === undefined || value === null) return undefined\n return String(value)\n}\n\n/**\n * Flatten parsed MIME headers to a `Record<string, string>`.\n *\n * mailparser returns `headers` as a `Map<string, unknown>`. `Object.entries` on\n * a Map yields an empty array (Maps have no enumerable own properties), so we\n * iterate Map entries directly, with a Record fallback for test fakes.\n */\nexport function extractHeaders(headers: ParsedMail['headers']): Record<string, string> {\n if (!headers) return {}\n const out: Record<string, string> = {}\n if (headers instanceof Map) {\n for (const [key, value] of headers.entries()) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[String(key)] = stringified\n }\n return out\n }\n for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[key] = stringified\n }\n return out\n}\n\nexport interface NormalizeMimeInboundOptions {\n /**\n * The parsed MIME message. Providers parse with their own `mailparser`\n * dependency (Gmail/IMAP) and pass the result here, so the hub stays free of a\n * MIME-parser dependency while still owning the normalization logic.\n */\n parsed: ParsedMail\n /** External identifier of the receiving channel (typically the account's email). */\n accountIdentifier: string\n /** Deterministic id used when the MIME message carries no `Message-ID` header. */\n fallbackMessageId: string\n /** Compute the conversation grouping id from the resolved message id + references. */\n resolveConversationId: (context: { messageId: string; references: string[] }) => string\n /** Fallback timestamp when the parsed message has no Date header. */\n fallbackDate?: Date\n /** Provider-specific fields merged into `channelMetadata`. */\n channelMetadata?: (parsed: ParsedMail) => Record<string, unknown>\n /** Provider-specific fields merged into `channelPayload`. */\n channelPayload?: (parsed: ParsedMail) => Record<string, unknown>\n}\n\n/**\n * Build the hub's canonical `NormalizedInboundMessage` from a parsed MIME\n * message. Providers supply the bits that genuinely differ (message-id\n * fallback, conversation grouping, extra metadata) and inherit the shared\n * threading / attachment / header logic.\n */\nexport function normalizeMimeInbound(options: NormalizeMimeInboundOptions): NormalizedInboundMessage {\n const { parsed } = options\n\n const messageId = stripBrackets(parsed.messageId) ?? options.fallbackMessageId\n const inReplyTo = stripBrackets(parsed.inReplyTo)\n const references = parseReferences(parsed.references)\n const conversationId = options.resolveConversationId({ messageId, references })\n\n const from = parsed.from?.value?.[0]\n const subject = parsed.subject?.trim() || undefined\n const bodyHtml = parsed.html && typeof parsed.html === 'string' ? parsed.html : undefined\n const bodyText = typeof parsed.text === 'string' ? parsed.text : undefined\n const body = bodyHtml ?? bodyText ?? ''\n const bodyFormat: 'text' | 'html' = bodyHtml ? 'html' : 'text'\n\n const attachments = normalizeAttachments(parsed.attachments ?? [])\n\n const channelMetadata: Record<string, unknown> = {\n ...(options.channelMetadata?.(parsed) ?? {}),\n messageId,\n inReplyTo: inReplyTo ?? null,\n references,\n headers: extractHeaders(parsed.headers),\n }\n\n const channelPayload: Record<string, unknown> = {\n subject,\n from: from ? { address: from.address, name: from.name } : null,\n to: parsed.to?.value ?? [],\n cc: parsed.cc?.value ?? [],\n bcc: parsed.bcc?.value ?? [],\n html: bodyHtml ?? null,\n text: bodyText ?? null,\n messageId,\n ...(options.channelPayload?.(parsed) ?? {}),\n }\n\n return {\n externalMessageId: messageId,\n externalConversationId: conversationId,\n senderIdentifier: from?.address ?? options.accountIdentifier,\n senderDisplayName: from?.name?.trim() || undefined,\n subject,\n body,\n bodyFormat,\n attachments,\n timestamp: parsed.date ? new Date(parsed.date) : options.fallbackDate ?? new Date(),\n replyToExternalId: inReplyTo ?? undefined,\n channelPayload,\n channelContentType: 'email/mime',\n channelMetadata,\n }\n}\n\n// \u2500\u2500 Provider sync cursor helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Encode a provider channel-state object into an opaque base64 sync cursor. */\nexport function encodeCursor(state: unknown): string {\n return Buffer.from(JSON.stringify(state)).toString('base64')\n}\n\n/** Decode a base64 sync cursor back into its object form, or `null` if malformed. */\nexport function decodeCursor(value: string | null | undefined): unknown {\n if (!value) return null\n try {\n return JSON.parse(Buffer.from(value, 'base64').toString('utf-8'))\n } catch {\n return null\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport type { NormalizedInboundMessage, NormalizedAttachment } from './adapter'\nimport { EMAIL_MAX_ATTACHMENT_BYTES } from './email-capabilities'\n\n/**\n * Aggregate ceiling for all attachments on a single inbound message. Inbound\n * mail is untrusted, so without a cap a malicious/large message would be fully\n * base64-buffered in memory (~1.33x raw bytes) and persisted. Allow a small\n * multiple of the per-attachment limit for legitimate multi-file emails.\n */\nconst TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES = EMAIL_MAX_ATTACHMENT_BYTES * 2\n\n/**\n * Shared email MIME helpers for the email channel providers (Gmail, IMAP).\n * Outbound assembly, inbound parsing, header/address normalization,\n * and threading-id extraction all live here so every provider shares one\n * correct implementation instead of copy-pasting (which previously let Gmail's\n * `extractHeaders` drift into a Map-handling bug that IMAP had already fixed).\n *\n * Provider-specific transport (Gmail History API, IMAP UID sync)\n * stays in each package \u2014 this module only owns the format-level plumbing.\n */\n\n// \u2500\u2500 Outbound MIME helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport function stringOrUndefined(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function toAddressList(value: unknown): string[] {\n if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter((v) => v.length > 0)\n if (typeof value === 'string') {\n return value\n .split(/[,;]\\s*/)\n .map((v) => v.trim())\n .filter((v) => v.length > 0)\n }\n return []\n}\n\nexport function referencesFromMeta(value: unknown): string[] | undefined {\n if (!Array.isArray(value)) return undefined\n return value.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Strip every `<tag>\u2026</tag>` block (and its contents) for a single tag name.\n * Inbound HTML is untrusted, so the matcher must resist evasion: the close tag\n * allows trailing attributes/whitespace (`</script >`, `</style foo>`), the `i`\n * flag covers mixed case, and the replacement loops until the string is stable\n * so a payload split across nested or reconstructed tags cannot survive a\n * single pass (`<scr<script>ipt>` collapsing back into `<script>`).\n *\n * The opening tag is also matched when it is truncated or never closed \u2014 a bare\n * `<script` or an unterminated `<script>\u2026` running to end-of-input is removed\n * outright \u2014 so no prefix of the element name can leak into the output.\n */\nfunction stripTagBlocks(html: string, tag: string): string {\n const blockPattern = new RegExp(\n `<${tag}\\\\b[^>]*(?:>[\\\\s\\\\S]*?(?:<\\\\/${tag}[^>]*>|$)|$)`,\n 'gi',\n )\n let previous: string\n let current = html\n do {\n previous = current\n current = current.replace(blockPattern, ' ')\n } while (current !== previous)\n return current\n}\n\n/**\n * Drop HTML comments (`<!-- \u2026 -->`), including an unterminated comment running\n * to end-of-input. Comments are stripped first because they can wrap content\n * that would otherwise survive tag removal (`<!--<script-->`), and a naive\n * filter that ignores them leaves a tag fragment behind.\n */\nfunction stripHtmlComments(html: string): string {\n return html.replace(/<!--[\\s\\S]*?(?:-->|$)/g, ' ')\n}\n\nconst BASIC_HTML_ENTITIES: Record<string, string> = {\n '&nbsp;': ' ',\n '&lt;': '<',\n '&gt;': '>',\n '&quot;': '\"',\n '&amp;': '&',\n}\n\n/**\n * Decode the handful of HTML entities we surface in plaintext in ONE\n * left-to-right pass. A single pass cannot double-unescape: characters produced\n * by a replacement (e.g. the `&` from `&amp;`) are never re-scanned, so\n * `&amp;lt;` decodes to the literal `&lt;` rather than collapsing into `<`.\n */\nfunction decodeBasicEntities(input: string): string {\n return input.replace(\n /&(?:nbsp|lt|gt|quot|amp);/gi,\n (match) => BASIC_HTML_ENTITIES[match.toLowerCase()] ?? match,\n )\n}\n\n/**\n * Remove every remaining `<\u2026>` tag, looping until the string is stable so a tag\n * reconstructed by a single pass (`<<div>div>`) cannot survive \u2014 the same\n * loop-until-stable shape `stripTagBlocks` uses. A raw `<` that is not part of a\n * tag (`a < b`) has no closing `>`, never matches, and is intentionally kept.\n */\nfunction stripRemainingTags(html: string): string {\n let previous: string\n let current = html\n do {\n previous = current\n current = current.replace(/<[^>]*>/g, '')\n } while (current !== previous)\n return current\n}\n\n/**\n * Convert untrusted inbound HTML to plaintext. Entities are decoded FIRST so any\n * entity-encoded markup (e.g. `&lt;script&gt;`) is normalized into real tag\n * syntax before the strippers run \u2014 otherwise decoding last would reintroduce a\n * `<script` fragment into the final output after sanitization had finished\n * (CodeQL `js/incomplete-multi-character-sanitization`). After decoding, all\n * tag removal (script/style blocks, then remaining tags) happens via\n * loop-until-stable passes, so no `<tag` prefix can leak into the plaintext.\n */\nexport function htmlToText(html: string): string {\n const decoded = decodeBasicEntities(html)\n const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(decoded), 'style'), 'script')\n .replace(/<br\\s*\\/?>(?=\\s*)/gi, '\\n')\n .replace(/<\\/p\\s*>/gi, '\\n\\n')\n return stripRemainingTags(stripped)\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\nexport function escapeQuotes(value: string): string {\n return value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')\n}\n\n/**\n * Collapse CR/LF/TAB in an email header value to a single space to prevent\n * RFC 5322 header injection \u2014 e.g. a Subject smuggling an extra\n * `Bcc:`/`Content-Type:` header or splitting the message into a body.\n * Collapsing (rather than folding) is safe for the short structured headers\n * we emit (Subject, addresses, Message-ID, References).\n */\nexport function sanitizeHeaderValue(value: string): string {\n return value.replace(/[\\r\\n\\t]+/g, ' ').trim()\n}\n\nexport function ensureBrackets(value: string): string {\n const trimmed = sanitizeHeaderValue(value)\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed\n return `<${trimmed}>`\n}\n\nfunction isPureAscii(value: string): boolean {\n // eslint-disable-next-line no-control-regex\n return /^[\\x00-\\x7F]*$/.test(value)\n}\n\n/**\n * Encode a single header value as an RFC 2047 \"B\" (base64) encoded-word when it\n * contains non-ASCII characters, so 8-bit text like \"Caf\u00E9\" survives strict MTAs\n * that treat header bytes as 7-bit ASCII. Pure-ASCII values are returned\n * unchanged. Apply only AFTER `sanitizeHeaderValue` so the CR/LF injection guard\n * still runs against the raw value.\n */\nexport function encodeHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n return `=?utf-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`\n}\n\n/**\n * Encode the display-name part of a single address header value, leaving the\n * `<addr@domain>` untouched (per RFC 2047, encoded-words are not permitted\n * inside the addr-spec). Inputs without a bracketed address are treated as a\n * bare display name / address and encoded only when non-ASCII.\n */\nexport function encodeAddressHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n const match = value.match(/^(.*?)(\\s*<[^>]*>)\\s*$/)\n if (match) {\n const [, displayPart, addrPart] = match\n const displayName = displayPart.replace(/^\"|\"$/g, '').trim()\n if (!displayName) return value\n return `${encodeHeaderWord(displayName)}${addrPart}`\n }\n return encodeHeaderWord(value)\n}\n\n/**\n * Generate an RFC 5322 Message-ID rooted in the sender's domain. Used as a\n * downstream idempotency key, so entropy comes from `crypto.randomUUID()`\n * rather than `Math.random()`.\n */\nexport function generateMessageId(fromAddress: string, fallbackDomain = 'localhost'): string {\n const domain = fromAddress.split('@')[1] ?? fallbackDomain\n return `<${crypto.randomUUID()}@${domain}>`\n}\n\nexport interface AssembleRfc2822Input {\n from: string\n to: string[]\n cc: string[]\n bcc: string[]\n subject: string | undefined\n text: string | undefined\n html: string | undefined\n inReplyTo: string | undefined\n references: string[] | undefined\n messageId: string\n}\n\n/**\n * Render one MIME body part's CTE header + body content. Non-ASCII bodies are\n * base64-encoded (CRLF-wrapped at 76 cols) and labelled\n * `Content-Transfer-Encoding: base64` so 8-bit text survives strict MTAs;\n * pure-ASCII bodies stay `7bit` and verbatim.\n */\nfunction encodeBodyPart(content: string): { cte: string; body: string } {\n if (isPureAscii(content)) return { cte: '7bit', body: content }\n const base64 = Buffer.from(content, 'utf-8').toString('base64')\n const wrapped = base64.match(/.{1,76}/g)?.join('\\r\\n') ?? base64\n return { cte: 'base64', body: wrapped }\n}\n\n/**\n * Assemble a raw RFC2822 message (used by transports that send the encoded\n * message directly, e.g. Gmail `users.messages.send`). Emits a\n * `multipart/alternative` body when both html and text are present, otherwise a\n * single-part text or html body.\n */\nexport function assembleRfc2822(input: AssembleRfc2822Input): Buffer {\n const boundary = `omc_${crypto.randomUUID()}`\n const headers: string[] = []\n headers.push(`From: ${encodeAddressHeaderWord(sanitizeHeaderValue(input.from))}`)\n headers.push(`To: ${input.to.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.cc.length) headers.push(`Cc: ${input.cc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.bcc.length) headers.push(`Bcc: ${input.bcc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.subject) headers.push(`Subject: ${encodeHeaderWord(sanitizeHeaderValue(input.subject))}`)\n headers.push(`Message-ID: ${ensureBrackets(input.messageId)}`)\n if (input.inReplyTo) headers.push(`In-Reply-To: ${ensureBrackets(input.inReplyTo)}`)\n if (input.references && input.references.length) {\n headers.push(`References: ${input.references.map(ensureBrackets).join(' ')}`)\n }\n headers.push('MIME-Version: 1.0')\n headers.push(`Date: ${new Date().toUTCString()}`)\n\n if (input.html && input.text) {\n headers.push(`Content-Type: multipart/alternative; boundary=\"${boundary}\"`)\n const textPart = encodeBodyPart(input.text)\n const htmlPart = encodeBodyPart(input.html)\n const body = [\n '',\n `--${boundary}`,\n 'Content-Type: text/plain; charset=utf-8',\n `Content-Transfer-Encoding: ${textPart.cte}`,\n '',\n textPart.body,\n `--${boundary}`,\n 'Content-Type: text/html; charset=utf-8',\n `Content-Transfer-Encoding: ${htmlPart.cte}`,\n '',\n htmlPart.body,\n `--${boundary}--`,\n '',\n ].join('\\r\\n')\n return Buffer.from(headers.join('\\r\\n') + body, 'utf-8')\n }\n\n if (input.html) {\n const htmlPart = encodeBodyPart(input.html)\n headers.push('Content-Type: text/html; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${htmlPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + htmlPart.body, 'utf-8')\n }\n\n const textPart = encodeBodyPart(input.text ?? '')\n headers.push('Content-Type: text/plain; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${textPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + textPart.body, 'utf-8')\n}\n\n// \u2500\u2500 Inbound MIME parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface ParsedMail {\n messageId?: string | null\n inReplyTo?: string | null\n references?: string | string[] | null\n from?: { value?: Array<{ address?: string; name?: string }> }\n to?: { value?: Array<{ address?: string; name?: string }> }\n cc?: { value?: Array<{ address?: string; name?: string }> }\n bcc?: { value?: Array<{ address?: string; name?: string }> }\n subject?: string | null\n html?: string | false\n text?: string\n date?: string | Date | null\n attachments?: ParsedAttachment[]\n headers?: Map<string, unknown> | Record<string, unknown>\n}\n\nexport interface ParsedAttachment {\n content?: Buffer | Uint8Array\n contentType?: string\n filename?: string\n size?: number\n contentDisposition?: string\n cid?: string\n}\n\nexport function stripBrackets(value: string | undefined | null): string | undefined {\n if (!value) return undefined\n const trimmed = value.trim()\n if (!trimmed) return undefined\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1)\n return trimmed\n}\n\nexport function parseReferences(value: string | string[] | undefined | null): string[] {\n if (!value) return []\n if (Array.isArray(value)) return value.map((v) => stripBrackets(v)).filter((v): v is string => Boolean(v))\n return value\n .split(/\\s+/)\n .map((segment) => stripBrackets(segment))\n .filter((segment): segment is string => Boolean(segment))\n}\n\nexport function normalizeAttachments(attachments: ParsedAttachment[]): NormalizedAttachment[] {\n const out: NormalizedAttachment[] = []\n let totalBytes = 0\n for (const att of attachments) {\n if (!att.content) continue\n const byteLength = att.content.byteLength\n if (byteLength > EMAIL_MAX_ATTACHMENT_BYTES) {\n console.warn(\n `[email-mime] dropping oversized inbound attachment \"${att.filename ?? 'attachment'}\" (${byteLength} bytes > ${EMAIL_MAX_ATTACHMENT_BYTES} cap)`,\n )\n continue\n }\n if (totalBytes + byteLength > TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES) {\n console.warn(\n `[email-mime] aggregate inbound attachment size exceeded ${TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES} bytes; dropping remaining attachments`,\n )\n break\n }\n totalBytes += byteLength\n const base64 = Buffer.isBuffer(att.content)\n ? att.content.toString('base64')\n : Buffer.from(att.content).toString('base64')\n out.push({\n url: `data:${att.contentType ?? 'application/octet-stream'};base64,${base64}`,\n mimeType: att.contentType ?? 'application/octet-stream',\n fileName: att.filename ?? 'attachment',\n fileSize: att.size,\n inline: Boolean(att.contentDisposition && /inline/i.test(att.contentDisposition)) || Boolean(att.cid),\n })\n }\n return out\n}\n\nfunction stringifyHeaderValue(value: unknown): string | undefined {\n if (typeof value === 'string') return value\n if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')\n if (value instanceof Date) return value.toISOString()\n if (value && typeof value === 'object') {\n try {\n return JSON.stringify(value)\n } catch {\n return undefined\n }\n }\n if (value === undefined || value === null) return undefined\n return String(value)\n}\n\n/**\n * Flatten parsed MIME headers to a `Record<string, string>`.\n *\n * mailparser returns `headers` as a `Map<string, unknown>`. `Object.entries` on\n * a Map yields an empty array (Maps have no enumerable own properties), so we\n * iterate Map entries directly, with a Record fallback for test fakes.\n */\nexport function extractHeaders(headers: ParsedMail['headers']): Record<string, string> {\n if (!headers) return {}\n const out: Record<string, string> = {}\n if (headers instanceof Map) {\n for (const [key, value] of headers.entries()) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[String(key)] = stringified\n }\n return out\n }\n for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[key] = stringified\n }\n return out\n}\n\nexport interface NormalizeMimeInboundOptions {\n /**\n * The parsed MIME message. Providers parse with their own `mailparser`\n * dependency (Gmail/IMAP) and pass the result here, so the hub stays free of a\n * MIME-parser dependency while still owning the normalization logic.\n */\n parsed: ParsedMail\n /** External identifier of the receiving channel (typically the account's email). */\n accountIdentifier: string\n /** Deterministic id used when the MIME message carries no `Message-ID` header. */\n fallbackMessageId: string\n /** Compute the conversation grouping id from the resolved message id + references. */\n resolveConversationId: (context: { messageId: string; references: string[] }) => string\n /** Fallback timestamp when the parsed message has no Date header. */\n fallbackDate?: Date\n /** Provider-specific fields merged into `channelMetadata`. */\n channelMetadata?: (parsed: ParsedMail) => Record<string, unknown>\n /** Provider-specific fields merged into `channelPayload`. */\n channelPayload?: (parsed: ParsedMail) => Record<string, unknown>\n}\n\n/**\n * Build the hub's canonical `NormalizedInboundMessage` from a parsed MIME\n * message. Providers supply the bits that genuinely differ (message-id\n * fallback, conversation grouping, extra metadata) and inherit the shared\n * threading / attachment / header logic.\n */\nexport function normalizeMimeInbound(options: NormalizeMimeInboundOptions): NormalizedInboundMessage {\n const { parsed } = options\n\n const messageId = stripBrackets(parsed.messageId) ?? options.fallbackMessageId\n const inReplyTo = stripBrackets(parsed.inReplyTo)\n const references = parseReferences(parsed.references)\n const conversationId = options.resolveConversationId({ messageId, references })\n\n const from = parsed.from?.value?.[0]\n const subject = parsed.subject?.trim() || undefined\n const bodyHtml = parsed.html && typeof parsed.html === 'string' ? parsed.html : undefined\n const bodyText = typeof parsed.text === 'string' ? parsed.text : undefined\n const body = bodyHtml ?? bodyText ?? ''\n const bodyFormat: 'text' | 'html' = bodyHtml ? 'html' : 'text'\n\n const attachments = normalizeAttachments(parsed.attachments ?? [])\n\n const channelMetadata: Record<string, unknown> = {\n ...(options.channelMetadata?.(parsed) ?? {}),\n messageId,\n inReplyTo: inReplyTo ?? null,\n references,\n headers: extractHeaders(parsed.headers),\n }\n\n const channelPayload: Record<string, unknown> = {\n subject,\n from: from ? { address: from.address, name: from.name } : null,\n to: parsed.to?.value ?? [],\n cc: parsed.cc?.value ?? [],\n bcc: parsed.bcc?.value ?? [],\n html: bodyHtml ?? null,\n text: bodyText ?? null,\n messageId,\n ...(options.channelPayload?.(parsed) ?? {}),\n }\n\n return {\n externalMessageId: messageId,\n externalConversationId: conversationId,\n senderIdentifier: from?.address ?? options.accountIdentifier,\n senderDisplayName: from?.name?.trim() || undefined,\n subject,\n body,\n bodyFormat,\n attachments,\n timestamp: parsed.date ? new Date(parsed.date) : options.fallbackDate ?? new Date(),\n replyToExternalId: inReplyTo ?? undefined,\n channelPayload,\n channelContentType: 'email/mime',\n channelMetadata,\n }\n}\n\n// \u2500\u2500 Provider sync cursor helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Encode a provider channel-state object into an opaque base64 sync cursor. */\nexport function encodeCursor(state: unknown): string {\n return Buffer.from(JSON.stringify(state)).toString('base64')\n}\n\n/** Decode a base64 sync cursor back into its object form, or `null` if malformed. */\nexport function decodeCursor(value: string | null | undefined): unknown {\n if (!value) return null\n try {\n return JSON.parse(Buffer.from(value, 'base64').toString('utf-8'))\n } catch {\n return null\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["textPart"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.5-develop.5162.1.eba42159b8",
3
+ "version": "0.6.5-develop.5169.1.d0671533ca",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -245,16 +245,16 @@
245
245
  "zod": "^4.4.3"
246
246
  },
247
247
  "peerDependencies": {
248
- "@open-mercato/ai-assistant": "0.6.5-develop.5162.1.eba42159b8",
249
- "@open-mercato/shared": "0.6.5-develop.5162.1.eba42159b8",
250
- "@open-mercato/ui": "0.6.5-develop.5162.1.eba42159b8",
248
+ "@open-mercato/ai-assistant": "0.6.5-develop.5169.1.d0671533ca",
249
+ "@open-mercato/shared": "0.6.5-develop.5169.1.d0671533ca",
250
+ "@open-mercato/ui": "0.6.5-develop.5169.1.d0671533ca",
251
251
  "react": "^19.0.0",
252
252
  "react-dom": "^19.0.0"
253
253
  },
254
254
  "devDependencies": {
255
- "@open-mercato/ai-assistant": "0.6.5-develop.5162.1.eba42159b8",
256
- "@open-mercato/shared": "0.6.5-develop.5162.1.eba42159b8",
257
- "@open-mercato/ui": "0.6.5-develop.5162.1.eba42159b8",
255
+ "@open-mercato/ai-assistant": "0.6.5-develop.5169.1.d0671533ca",
256
+ "@open-mercato/shared": "0.6.5-develop.5169.1.d0671533ca",
257
+ "@open-mercato/ui": "0.6.5-develop.5169.1.d0671533ca",
258
258
  "@testing-library/dom": "^10.4.1",
259
259
  "@testing-library/jest-dom": "^6.9.1",
260
260
  "@testing-library/react": "^16.3.1",
@@ -102,12 +102,37 @@ function decodeBasicEntities(input: string): string {
102
102
  )
103
103
  }
104
104
 
105
+ /**
106
+ * Remove every remaining `<…>` tag, looping until the string is stable so a tag
107
+ * reconstructed by a single pass (`<<div>div>`) cannot survive — the same
108
+ * loop-until-stable shape `stripTagBlocks` uses. A raw `<` that is not part of a
109
+ * tag (`a < b`) has no closing `>`, never matches, and is intentionally kept.
110
+ */
111
+ function stripRemainingTags(html: string): string {
112
+ let previous: string
113
+ let current = html
114
+ do {
115
+ previous = current
116
+ current = current.replace(/<[^>]*>/g, '')
117
+ } while (current !== previous)
118
+ return current
119
+ }
120
+
121
+ /**
122
+ * Convert untrusted inbound HTML to plaintext. Entities are decoded FIRST so any
123
+ * entity-encoded markup (e.g. `&lt;script&gt;`) is normalized into real tag
124
+ * syntax before the strippers run — otherwise decoding last would reintroduce a
125
+ * `<script` fragment into the final output after sanitization had finished
126
+ * (CodeQL `js/incomplete-multi-character-sanitization`). After decoding, all
127
+ * tag removal (script/style blocks, then remaining tags) happens via
128
+ * loop-until-stable passes, so no `<tag` prefix can leak into the plaintext.
129
+ */
105
130
  export function htmlToText(html: string): string {
106
- const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(html), 'style'), 'script')
131
+ const decoded = decodeBasicEntities(html)
132
+ const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(decoded), 'style'), 'script')
107
133
  .replace(/<br\s*\/?>(?=\s*)/gi, '\n')
108
134
  .replace(/<\/p\s*>/gi, '\n\n')
109
- return decodeBasicEntities(stripped)
110
- .replace(/<[^>]+>/g, '')
135
+ return stripRemainingTags(stripped)
111
136
  .replace(/\n{3,}/g, '\n\n')
112
137
  .trim()
113
138
  }