// WhatsApp messages -> RFC 5322 (eml) / mboxrd. The base piece for neomutt: // `--format mbox` opens directly with `neomutt -f`, and a Maildir bridge (like gwsmail) // only has to write the `--format eml` output to ~/Mail///new. // // Conventions (the sending side must follow them): // addresses the JID as is: 5491122334455@c.us, 1203630...@g.us, 1234@lid // Message-ID (stanzaId = the "stanzaId" JSON field) // In-Reply-To -> neomutt threads quoted replies // Reply-To in groups, the group (so `r` answers the group, not privately) // X-WA-Id serialized ID, for later actions (react, delete, download) // X-WA-Chat-Id chat it belongs to import crypto from 'node:crypto'; import { describeContent } from './text.js'; const ASCII = /^[\x20-\x7e]*$/; function encodedWords(s) { const words = []; let chunk = ''; for (const ch of s) { if (Buffer.byteLength(chunk + ch) > 45) { words.push(chunk); chunk = ''; } chunk += ch; } if (chunk) words.push(chunk); return words.map((w) => `=?UTF-8?B?${Buffer.from(w).toString('base64')}?=`).join('\n '); } const headerText = (s) => (ASCII.test(s) ? s : encodedWords(s)); function address(name, addr) { if (!addr) return 'undisclosed-recipients:;'; if (!name || name === addr.split('@')[0]) return `<${addr}>`; const display = ASCII.test(name) ? `"${name.replace(/[\\"]/g, '\\$&')}"` : encodedWords(name); return `${display} <${addr}>`; } function rfc2822Date(ts) { const d = new Date(ts * 1000); const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const p = (n) => String(n).padStart(2, '0'); const off = -d.getTimezoneOffset(); const sign = off >= 0 ? '+' : '-'; return `${days[d.getDay()]}, ${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()} ` + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ` + `${sign}${p(Math.floor(Math.abs(off) / 60))}${p(Math.abs(off) % 60)}`; } function quotedPrintable(text) { const out = []; for (const line of text.split('\n')) { const bytes = Buffer.from(line); let cur = ''; for (let i = 0; i < bytes.length; i++) { const b = bytes[i]; const last = i === bytes.length - 1; let tok; // "From " at the start of a line becomes =46rom: the mbox then needs no escaping // (and mboxo readers such as mutt do not show ">From"). const fromLine = i === 0 && b === 70 && line.startsWith('From '); if (!fromLine && ((b >= 33 && b <= 126 && b !== 61) || ((b === 32 || b === 9) && !last))) { tok = String.fromCharCode(b); } if (!tok) tok = `=${b.toString(16).toUpperCase().padStart(2, '0')}`; if (cur.length + tok.length > 75) { out.push(`${cur}=`); cur = ''; } cur += tok; } out.push(cur); } return out.join('\n'); } const base64Lines = (b64) => b64.replace(/\s+/g, '').match(/.{1,76}/g)?.join('\n') ?? ''; function filenameParams(name) { if (ASCII.test(name)) return `; name="${name.replace(/"/g, '')}"`; return `; name="${encodedWords(name).replace(/\n /g, '')}"`; } function dispositionParams(name) { if (ASCII.test(name)) return `; filename="${name.replace(/"/g, '')}"`; return `; filename*=UTF-8''${encodeURIComponent(name)}`; } function bodyText(m) { const lines = []; const content = describeContent(m); if (m.hasMedia && !m.media?.data) { const size = m.media?.size ? `, ${Math.round(m.media.size / 1024)} KB` : ''; lines.push(`[attachment not downloaded: ${m.media?.mimetype ?? m.type}${size}]`); lines.push(`[wa messages download --params '{"messageId":"${m.id}"}']`); if (m.body) lines.push('', m.body); } else { lines.push(m.hasMedia ? m.body : content); } if (m.location) lines.push('', `https://maps.google.com/?q=${m.location.latitude},${m.location.longitude}`); if (m.links?.length && m.type !== 'chat') lines.push('', ...m.links); return lines.join('\n'); } export function messageToEml(m) { const isGroup = m.chatId?.endsWith('@g.us'); const subject = describeContent(m).split('\n')[0].slice(0, 80) || '(no text)'; const from = address(m.senderName, m.sender); const to = isGroup ? address(m.chatName, m.chatId) : address(m.fromMe ? m.chatName : null, m.fromMe ? m.chatId : m.to); const h = [ `From: ${from}`, `To: ${to}`, ...(isGroup ? [`Reply-To: ${address(m.chatName, m.chatId)}`] : []), `Subject: ${headerText(subject)}`, `Date: ${rfc2822Date(m.timestamp)}`, `Message-ID: <${m.stanzaId}@wa>`, ...(m.quotedStanzaId ? [`In-Reply-To: <${m.quotedStanzaId}@wa>`, `References: <${m.quotedStanzaId}@wa>`] : []), `X-WA-Id: ${m.id}`, `X-WA-Chat-Id: ${m.chatId}`, ...(m.chatName ? [`X-WA-Chat-Name: ${headerText(m.chatName)}`] : []), `X-WA-Type: ${m.type}`, `X-WA-From-Me: ${m.fromMe ? 'yes' : 'no'}`, ...(m.isForwarded ? ['X-WA-Forwarded: yes'] : []), 'MIME-Version: 1.0', ]; const textPart = [ 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', quotedPrintable(bodyText(m)), ].join('\n'); const attachments = []; if (m.media?.data) { const name = m.media.filename || `${m.stanzaId}`; const mimetype = (m.media.mimetype || 'application/octet-stream').split(';')[0]; attachments.push([ `Content-Type: ${mimetype}${filenameParams(name)}`, 'Content-Transfer-Encoding: base64', `Content-Disposition: ${mimetype.startsWith('image/') ? 'inline' : 'attachment'}${dispositionParams(name)}`, '', base64Lines(m.media.data), ].join('\n')); } (m.vCards || []).forEach((vc, i) => attachments.push([ `Content-Type: text/vcard; charset=utf-8; name="contacto-${i + 1}.vcf"`, 'Content-Transfer-Encoding: quoted-printable', `Content-Disposition: attachment; filename="contacto-${i + 1}.vcf"`, '', quotedPrintable(vc.replace(/\r\n/g, '\n')), ].join('\n'))); if (!attachments.length) return `${h.join('\n')}\n${textPart}\n`; const boundary = `wa-${crypto.randomBytes(12).toString('hex')}`; return [ ...h, `Content-Type: multipart/mixed; boundary="${boundary}"`, '', ...[textPart, ...attachments].map((part) => `--${boundary}\n${part}`), `--${boundary}--`, '', ].join('\n'); } function asMessages(data) { const list = Array.isArray(data) ? data : [data]; if (!list.every((m) => m && typeof m === 'object' && 'stanzaId' in m)) { throw Object.assign(new Error('the eml/mbox formats only apply to messages'), { code: 'EINVAL' }); } return list; } export function toEml(data) { const list = asMessages(data); if (list.length !== 1) { throw Object.assign(new Error(`eml is for a single message (there are ${list.length}); use --format mbox`), { code: 'EINVAL' }); } return messageToEml(list[0]).replace(/\n$/, ''); } export function toMbox(data) { return asMessages(data) .slice() .sort((a, b) => a.timestamp - b.timestamp) .map((m) => { const asctime = new Date(m.timestamp * 1000).toUTCString() .replace(/^(\w+), (\d+) (\w+) (\d+) ([\d:]+) GMT$/, (_, dow, d, mon, y, t) => `${dow} ${mon} ${d.padStart(2)} ${t} ${y}`); const body = messageToEml(m).replace(/^(>*From )/gm, '>$1'); return `From ${m.sender || 'unknown@wa'} ${asctime}\n${body}`; }) .join('\n') .replace(/\n$/, ''); }