aboutsummaryrefslogtreecommitdiffstats
path: root/src/wamail/mime.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/wamail/mime.js')
-rw-r--r--src/wamail/mime.js180
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;
+}