diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
| commit | d80640d01eeb84907e36ff4af33add9081eda17c (patch) | |
| tree | 27698b34e06e28d111c2196700015d9c82f78274 /src/wamail/mime.js | |
| download | wa-cli-main.tar.gz wa-cli-main.zip | |
Diffstat (limited to 'src/wamail/mime.js')
| -rw-r--r-- | src/wamail/mime.js | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/src/wamail/mime.js b/src/wamail/mime.js new file mode 100644 index 0000000..4d0abfa --- /dev/null +++ b/src/wamail/mime.js @@ -0,0 +1,180 @@ +// Minimal RFC 5322/MIME parser for what neomutt hands to `sendmail`: +// RFC 2047 headers, (nested) multipart, quoted-printable/base64/8bit, charsets, +// RFC 2231 filenames and text/plain format=flowed (RFC 3676). + +const decoderFor = (charset) => { + try { + return new TextDecoder((charset || 'utf-8').toLowerCase().replace(/^us-ascii$/, 'utf-8')); + } catch { + return new TextDecoder('utf-8'); + } +}; + +export function decodeWords(s) { + // Whitespace between adjacent encoded-words does not count. + return s.replace(/(=\?[^?]+\?[BbQq]\?[^?]*\?=)\s+(?==\?)/g, '$1').replace( + /=\?([^?*]+)(?:\*[^?]*)?\?([BbQq])\?([^?]*)\?=/g, + (_, charset, enc, text) => { + const bytes = enc.toUpperCase() === 'B' + ? Buffer.from(text, 'base64') + : Buffer.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); + return decoderFor(charset).decode(bytes); + }, + ); +} + +function splitHeaderBody(buf) { + const text = buf.toString('latin1'); + const m = /\r?\n\r?\n/.exec(text); + if (!m) return [text, Buffer.alloc(0)]; + return [text.slice(0, m.index), buf.subarray(m.index + m[0].length)]; +} + +export function parseHeaders(raw) { + const headers = new Map(); + for (const line of raw.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) { + const i = line.indexOf(':'); + if (i <= 0) continue; + const name = line.slice(0, i).trim().toLowerCase(); + // Raw headers arrive as latin1 (bytes); if they are 8-bit UTF-8, decode again. + const value = Buffer.from(line.slice(i + 1).trim(), 'latin1').toString('utf8'); + if (!headers.has(name)) headers.set(name, []); + headers.get(name).push(value); + } + return headers; +} + +// "text/plain; charset=utf-8; name*=utf-8''a%20b" -> { value, params } +export function parseParams(header = '') { + const [value, ...rest] = header.split(';'); + const params = {}; + const continuations = {}; + for (const part of rest) { + const i = part.indexOf('='); + if (i < 0) continue; + let key = part.slice(0, i).trim().toLowerCase(); + let val = part.slice(i + 1).trim().replace(/^"(.*)"$/s, '$1'); + const cont = /^(.+?)\*(\d+)(\*)?$/.exec(key); + if (cont) { + (continuations[cont[1]] ||= []).push([Number(cont[2]), val, Boolean(cont[3])]); + continue; + } + if (key.endsWith('*')) { + key = key.slice(0, -1); + val = decode2231(val); + } + params[key] = decodeWords(val); + } + for (const [key, parts] of Object.entries(continuations)) { + parts.sort((a, b) => a[0] - b[0]); + const encoded = parts[0][2]; + const joined = parts.map((p) => p[1]).join(''); + params[key] = encoded ? decode2231(joined) : decodeWords(joined); + } + return { value: value.trim().toLowerCase(), params }; +} + +function decode2231(val) { + const m = /^([^']*)'[^']*'(.*)$/.exec(val); + if (!m) return val; + const bytes = Buffer.from(m[2].replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); + return decoderFor(m[1]).decode(bytes); +} + +function decodeBody(buf, encoding = '') { + switch (encoding.toLowerCase()) { + case 'base64': + return Buffer.from(buf.toString('latin1').replace(/[^A-Za-z0-9+/=]/g, ''), 'base64'); + case 'quoted-printable': { + const s = buf.toString('latin1').replace(/=\r?\n/g, ''); + return Buffer.from(s.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); + } + default: + return buf; + } +} + +// RFC 3676: lines ending in a space continue on the next one. +export function unflow(text, delsp = false) { + const out = []; + let cur = null; + for (let line of text.split(/\r?\n/)) { + if (line !== '-- ' && line.startsWith(' ')) line = line.slice(1); // space-stuffing + const soft = line.endsWith(' ') && line !== '-- '; + const piece = soft && delsp ? line.slice(0, -1) : line; + cur = cur === null ? piece : cur + piece; + if (!soft) { + out.push(cur); + cur = null; + } + } + if (cur !== null) out.push(cur); + return out.join('\n'); +} + +function parsePart(buf, parts) { + const [rawHeaders, body] = splitHeaderBody(buf); + const headers = parseHeaders(rawHeaders); + const get = (n) => headers.get(n)?.[0]; + const ctype = parseParams(get('content-type') || 'text/plain; charset=us-ascii'); + const disposition = parseParams(get('content-disposition') || ''); + + if (ctype.value.startsWith('multipart/') && ctype.params.boundary) { + const text = body.toString('latin1'); + const delim = `--${ctype.params.boundary}`; + const chunks = text.split(new RegExp(`(?:^|\\r?\\n)${delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?[ \\t]*(?:\\r?\\n|$)`)); + for (const chunk of chunks.slice(1, -1)) parsePart(Buffer.from(chunk, 'latin1'), parts); + // with no closing "--boundary--", the last chunk is a part too + const last = chunks[chunks.length - 1]; + if (chunks.length > 1 && !new RegExp(`${delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}--`).test(text) && last.trim()) { + parsePart(Buffer.from(last, 'latin1'), parts); + } + return headers; + } + + const data = decodeBody(body, get('content-transfer-encoding')); + const filename = disposition.params.filename || ctype.params.name || null; + const isAttachment = disposition.value === 'attachment' || (filename && !ctype.value.startsWith('text/')) + || !ctype.value.startsWith('text/'); + if (!isAttachment) { + let text = decoderFor(ctype.params.charset).decode(data); + if ((ctype.params.format || '').toLowerCase() === 'flowed') { + text = unflow(text, (ctype.params.delsp || '').toLowerCase() === 'yes'); + } + parts.push({ kind: 'text', contentType: ctype.value, text }); + } else { + parts.push({ kind: 'attachment', contentType: ctype.value, filename, data }); + } + return headers; +} + +// Devuelve { headers: Map, text, html, attachments: [{filename, contentType, data}] } +export function parseMessage(input) { + const buf = Buffer.isBuffer(input) ? input : Buffer.from(input); + const parts = []; + const headers = parsePart(buf, parts); + const texts = parts.filter((p) => p.kind === 'text'); + return { + headers, + header: (name) => { + const v = headers.get(name.toLowerCase())?.[0]; + return v === undefined ? undefined : decodeWords(v); + }, + text: texts.find((p) => p.contentType === 'text/plain')?.text ?? null, + html: texts.find((p) => p.contentType === 'text/html')?.text ?? null, + attachments: parts.filter((p) => p.kind === 'attachment'), + }; +} + +// "Juan <5491@c.us>, 5492@c.us" -> ['5491@c.us', '5492@c.us'] +export function addresses(value = '') { + const out = []; + // drop comments and quoted names before splitting on commas + const cleaned = value.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/\([^)]*\)/g, ''); + for (const part of cleaned.split(',')) { + const angle = /<([^>]+)>/.exec(part); + const addr = (angle ? angle[1] : part).trim(); + if (addr) out.push(addr); + } + return out; +} |