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 | |
| download | wa-cli-main.tar.gz wa-cli-main.zip | |
Diffstat (limited to 'src/wamail')
| -rw-r--r-- | src/wamail/mime.js | 180 | ||||
| -rw-r--r-- | src/wamail/neomutt.js | 46 | ||||
| -rw-r--r-- | src/wamail/send.js | 118 | ||||
| -rw-r--r-- | src/wamail/store.js | 172 | ||||
| -rw-r--r-- | src/wamail/sync.js | 235 |
5 files changed, 751 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; +} diff --git a/src/wamail/neomutt.js b/src/wamail/neomutt.js new file mode 100644 index 0000000..22d6dc8 --- /dev/null +++ b/src/wamail/neomutt.js @@ -0,0 +1,46 @@ +// Mailbox list for neomutt, regenerated on every sync: one mailbox per chat, ordered +// like WhatsApp (pinned, then by last activity; archived at the end). +// The neomutt account includes it with `source`. + +import fs from 'node:fs'; +import path from 'node:path'; +import { CACHE_DIR, ensureMaildir } from './store.js'; + +export function mailboxesPath(account) { + return path.join(CACHE_DIR, account, 'mailboxes.muttrc'); +} + +// Names come from other people (group names): no quotes, backticks or `$`, +// which neomutt would interpret on `source`. +const safe = (s) => String(s ?? '').replace(/[\x00-\x1f'"`$\\]/g, '').trim() || '?'; +const regexEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export function writeMailboxes(account, root, state) { + const chats = Object.values(state.chats).filter((c) => c.dir); + const byActivity = (a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0); + const active = chats.filter((c) => !c.archived).sort((a, b) => (Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))) || byActivity(a, b)); + const archived = chats.filter((c) => c.archived).sort(byActivity); + + const lines = ['# Generated by `wamail sync` — do not edit by hand.']; + if (active[0]) lines.push(`set spoolfile = '${path.join(root, active[0].dir)}'`); + if (state.me?.id) lines.push(`alternates '^${regexEscape(state.me.id)}$'`); + for (const c of active) { + const icon = c.isGroup ? '' : ''; + lines.push(`named-mailboxes '${icon} ${c.pinned ? ' ' : ''}${safe(c.name)}' '${path.join(root, c.dir)}'`); + } + for (const c of archived) lines.push(`named-mailboxes ' ${safe(c.name)}' '${path.join(root, c.dir)}'`); + ensureMaildir(path.join(root, 'Drafts')); // neomutt rechaza buzones inexistentes + lines.push(`named-mailboxes ' Drafts' '${path.join(root, 'Drafts')}'`); + + const file = mailboxesPath(account); + const text = `${lines.join('\n')}\n`; + let current = null; + try { + current = fs.readFileSync(file, 'utf8'); + } catch { /* primera vez */ } + if (current === text) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(`${file}.tmp`, text); + fs.renameSync(`${file}.tmp`, file); + return file; +} diff --git a/src/wamail/send.js b/src/wamail/send.js new file mode 100644 index 0000000..7ce1c0d --- /dev/null +++ b/src/wamail/send.js @@ -0,0 +1,118 @@ +// wamail send: neomutt's `sendmail`. Reads the RFC 822 message from stdin and sends it over WhatsApp. +// +// recipients arguments after `--` (as neomutt passes them) or To/Cc/Bcc; +// they must be JIDs (5491122334455@c.us, ...@g.us, ...@lid) or numbers +// quote if there is In-Reply-To <stanzaId@wa> AND the body keeps quoted text (> ...), +// it is sent as a quoted reply and the quote is removed from the text +// replies if the original message's chat is among the recipients, it only goes there +// (a group-reply does not also write privately to the author) +// attachments a single image/video attachment: the text becomes its caption; otherwise the +// text, then each attachment (anything not image/video/audio, as a document) + +import fs from 'node:fs'; +import path from 'node:path'; +import mime from 'mime'; +import { addresses, parseMessage } from './mime.js'; +import { STATE_DIR, loadState, stanzaFileId } from './store.js'; + +const JID_RE = /^[^\s@]+@(c\.us|g\.us|lid|s\.whatsapp\.net)$/; + +export function toChatId(addr) { + const a = addr.trim().replace(/^<|>$/g, ''); + if (JID_RE.test(a)) return a.replace(/@s\.whatsapp\.net$/, '@c.us'); + const local = a.split('@')[0]; + if (/^\+?[\d\s().-]{6,}$/.test(local)) return local.replace(/\D/g, ''); + throw new Error(`"${addr}" is not a WhatsApp recipient (use the JID, e.g. 5491122334455@c.us, or the number)`); +} + +export function stripSignature(text) { + const lines = text.split('\n'); + const i = lines.lastIndexOf('-- '); + return (i >= 0 ? lines.slice(0, i) : lines).join('\n'); +} + +// Removes the quoted lines ("> ...") and the attribution line before them ("On ..., X wrote:"). +export function stripQuote(text) { + const lines = text.split('\n'); + const isQuote = (l) => /^ {0,3}>/.test(l); + if (!lines.some(isQuote)) return { body: text.trim(), hadQuote: false }; + const keep = []; + for (let i = 0; i < lines.length; i++) { + if (isQuote(lines[i])) continue; + const next = lines.slice(i + 1).find((l) => l.trim() !== ''); + if (lines[i].trim().endsWith(':') && next !== undefined && isQuote(next)) continue; + keep.push(lines[i]); + } + return { body: keep.join('\n').replace(/\n{3,}/g, '\n\n').trim(), hadQuote: true }; +} + +const htmlToText = (html) => html + .replace(/<(br|\/p|\/div|\/li)[^>]*>/gi, '\n').replace(/<[^>]+>/g, '') + .replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + +// Builds the list of sends without touching WhatsApp (testable). +export function planSend({ raw, argv = [], state }) { + const msg = parseMessage(raw); + const dash = argv.indexOf('--'); + let rcpts = (dash >= 0 ? argv.slice(dash + 1) : argv.filter((a) => !a.startsWith('-'))).filter(Boolean); + if (!rcpts.length) { + rcpts = ['To', 'Cc', 'Bcc'].flatMap((h) => addresses(msg.header(h) || '')); + } + if (!rcpts.length) throw new Error('the message has no recipients'); + + const irt = /<([^<>@\s]+)@wa>/.exec(msg.header('In-Reply-To') || ''); + const quoted = irt ? (state.msgs[stanzaFileId(irt[1])] ?? state.tombstones[stanzaFileId(irt[1])]) : null; + + const text = stripSignature(msg.text ?? (msg.html ? htmlToText(msg.html) : '')); + // "> " lines are only a quote in a reply; in a new message they are text. + const { body, hadQuote } = irt ? stripQuote(text) : { body: text.trim(), hadQuote: false }; + const quotedMessageId = hadQuote && quoted ? quoted.id : undefined; + + let chatIds = [...new Set(rcpts.map(toChatId))]; + if (quoted && chatIds.includes(quoted.chat)) chatIds = [quoted.chat]; + + const atts = msg.attachments.filter((a) => a.data.length); + const steps = []; + for (const chatId of chatIds) { + const visual = (a) => /^(image|video)\//.test(a.contentType); + if (atts.length === 1 && visual(atts[0])) { + steps.push({ chatId, body: body || undefined, attachment: atts[0], quotedMessageId }); + continue; + } + if (body) steps.push({ chatId, body, quotedMessageId }); + atts.forEach((a, i) => steps.push({ chatId, attachment: a, quotedMessageId: !body && i === 0 ? quotedMessageId : undefined })); + } + if (!steps.length) throw new Error('empty message: no text and no attachments'); + return { steps, chatIds, quotedWanted: hadQuote && Boolean(irt), quotedFound: Boolean(quotedMessageId) }; +} + +export async function sendRaw({ account, rpc, raw, argv, log = () => {} }) { + const plan = planSend({ raw, argv, state: loadState(account) }); + if (plan.quotedWanted && !plan.quotedFound) log('the quoted message is not synced: sending without the quote'); + fs.mkdirSync(STATE_DIR, { recursive: true }); + const tmp = fs.mkdtempSync(path.join(STATE_DIR, 'send-')); + try { + let n = 0; + for (const step of plan.steps) { + const params = { chatId: step.chatId }; + if (step.body) params.body = step.body; + if (step.quotedMessageId) params.quotedMessageId = step.quotedMessageId; + if (step.attachment) { + const a = step.attachment; + let name = (a.filename || 'adjunto').replace(/[/\0]/g, '_'); + if (!path.extname(name)) name += `.${mime.getExtension(a.contentType) || 'bin'}`; + // one directory per attachment: WhatsApp shows the file name as is + const file = path.join(tmp, String(n++), name); + fs.mkdirSync(path.dirname(file)); + fs.writeFileSync(file, a.data); + params.media = file; + if (!/^(image|video|audio)\//.test(a.contentType)) params.asDocument = true; + } + const sent = await rpc('messages.send', params); + log(`sent to ${step.chatId}${step.attachment ? ` (${step.attachment.filename ?? step.attachment.contentType})` : ''} id=${sent?.id ?? '?'}`); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + return plan.chatIds; +} diff --git a/src/wamail/store.js b/src/wamail/store.js new file mode 100644 index 0000000..fc8a6e0 --- /dev/null +++ b/src/wamail/store.js @@ -0,0 +1,172 @@ +// Per-chat Maildir + persistent state + lock, for wamail. +// +// <maildir>/<chat-dir>/{new,cur,tmp}/<ts>.<stanzaId>.wa[:2,<flags>] +// ~/.local/state/wamail/<account>.json last synced state (base for the diff) +// ~/.local/state/wamail/<account>.lock one sync at a time (timer, hook, neomutt macro) + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const HOME = os.homedir(); +export const STATE_DIR = path.join(process.env.XDG_STATE_HOME || path.join(HOME, '.local/state'), 'wamail'); +export const CACHE_DIR = path.join(process.env.XDG_CACHE_HOME || path.join(HOME, '.cache'), 'wamail'); + +const FILE_RE = /^(\d+)\.([A-Za-z0-9_-]+)\.wa(?::2,([A-Za-z]*))?$/; + +export const expandHome = (p) => p.replace(/^~(?=$|\/)/, HOME); + +// ---------------------------------------------------------------- nombres + +export function slug(name) { + return String(name || '') + .normalize('NFD').replace(/\p{M}/gu, '') + .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + .slice(0, 32).replace(/-+$/, ''); +} + +// Stable directory for a chat: chosen once and kept in the state even if the chat +// name changes later. +export function chatDirName(chatId, name, taken) { + const user = chatId.split('@')[0].replace(/\D/g, ''); + const base = slug(name) || (chatId.endsWith('@g.us') ? 'grupo' : 'chat'); + for (let n = 6; n <= user.length + 1; n++) { + const candidate = `${base}-${user.slice(-n) || n}`; + if (!taken.has(candidate)) return candidate; + } + let i = 2; + while (taken.has(`${base}-${user}-${i}`)) i++; + return `${base}-${user}-${i}`; +} + +export const stanzaFileId = (stanzaId) => String(stanzaId).replace(/[^A-Za-z0-9_-]/g, '_'); + +// ---------------------------------------------------------------- Maildir + +export function ensureMaildir(dir) { + for (const sub of ['new', 'cur', 'tmp']) fs.mkdirSync(path.join(dir, sub), { recursive: true }); +} + +// { [fileId]: { path, sub, flags: Set, ts } } — reads new/ before cur/ so a message that +// neomutt moves from new to cur during the scan is not lost. +export function scanDir(dir) { + const out = {}; + for (const sub of ['new', 'cur']) { + let names; + try { + names = fs.readdirSync(path.join(dir, sub)); + } catch { + continue; + } + for (const name of names) { + const m = FILE_RE.exec(name); + if (!m) continue; + const entry = { path: path.join(dir, sub, name), sub, flags: new Set(m[3] || ''), ts: Number(m[1]) }; + const prev = out[m[2]]; + if (prev) { + // duplicate (hook/timer race): keep the one in cur/ + const drop = prev.sub === 'cur' ? entry : prev; + fs.rmSync(drop.path, { force: true }); + if (drop === prev) out[m[2]] = entry; + continue; + } + out[m[2]] = entry; + } + } + return out; +} + +export function writeMessage(dir, { fileId, ts, raw, seen }) { + ensureMaildir(dir); + const base = `${ts}.${fileId}.wa`; + const tmp = path.join(dir, 'tmp', `${base}.${process.pid}`); + const dest = seen ? path.join(dir, 'cur', `${base}:2,S`) : path.join(dir, 'new', base); + fs.writeFileSync(tmp, raw); + fs.utimesSync(tmp, ts, ts); + fs.renameSync(tmp, dest); + return { path: dest, sub: seen ? 'cur' : 'new', flags: new Set(seen ? 'S' : ''), ts }; +} + +// Changes the flags of a file (moving it to cur/). Returns the updated entry. +export function setFlags(dir, fileId, entry, flags) { + const want = [...flags].sort().join(''); + if (entry.sub === 'cur' && [...entry.flags].sort().join('') === want) return entry; + const dest = path.join(dir, 'cur', `${entry.ts}.${fileId}.wa:2,${want}`); + try { + fs.renameSync(entry.path, dest); + } catch (e) { + if (e.code === 'ENOENT') return entry; // neomutt moved/deleted it meanwhile + throw e; + } + return { ...entry, path: dest, sub: 'cur', flags: new Set(want) }; +} + +// ---------------------------------------------------------------- estado + +export function statePath(account) { + return path.join(STATE_DIR, `${account}.json`); +} + +export function loadState(account) { + try { + const s = JSON.parse(fs.readFileSync(statePath(account), 'utf8')); + if (s.version === 1) return { chats: {}, msgs: {}, tombstones: {}, ...s }; + } catch (e) { + if (e.code !== 'ENOENT') throw new Error(`corrupt state in ${statePath(account)}: ${e.message}`); + } + return { version: 1, chats: {}, msgs: {}, tombstones: {} }; +} + +export function saveState(account, state) { + fs.mkdirSync(STATE_DIR, { recursive: true }); + const file = statePath(account); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state)); + fs.renameSync(tmp, file); +} + +// ---------------------------------------------------------------- lock + +function alive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === 'EPERM'; + } +} + +export async function withLock(account, { waitMs = 120000, onWait } = {}, fn) { + fs.mkdirSync(STATE_DIR, { recursive: true }); + const file = path.join(STATE_DIR, `${account}.lock`); + const deadline = Date.now() + waitMs; + let warned = false; + for (;;) { + try { + fs.writeFileSync(file, `${process.pid}\n`, { flag: 'wx' }); + break; + } catch (e) { + if (e.code !== 'EEXIST') throw e; + const pid = Number.parseInt(fs.readFileSync(file, 'utf8'), 10); + if (!pid || !alive(pid)) { + fs.rmSync(file, { force: true }); + continue; + } + if (Date.now() > deadline) throw new Error(`another wamail (pid ${pid}) has held the lock for too long`); + if (!warned) onWait?.(pid); + warned = true; + await new Promise((r) => setTimeout(r, 300)); + } + } + const release = () => { + try { + if (Number.parseInt(fs.readFileSync(file, 'utf8'), 10) === process.pid) fs.rmSync(file, { force: true }); + } catch { /* already gone */ } + }; + process.once('exit', release); + try { + return await fn(); + } finally { + release(); + } +} diff --git a/src/wamail/sync.js b/src/wamail/sync.js new file mode 100644 index 0000000..ce244e1 --- /dev/null +++ b/src/wamail/sync.js @@ -0,0 +1,235 @@ +// wamail sync: WhatsApp (the wa-cli daemon) <-> Maildir, one folder per chat. +// +// local -> WhatsApp (diff against the saved state): +// read every unread message of a chat -> chats.read mark one as unread -> chats.unread +// F (flag) -> messages.star / unstar delete -> local only (not downloaded again) +// WhatsApp -> local: +// new messages (backfill of the last N days the first time), media up to mediaMaxBytes, +// read/unread when the remote counter changes, names/archived state for the sidebar. + +import fs from 'node:fs'; +import path from 'node:path'; +import { messageToEml } from '../format/mail.js'; +import { writeMailboxes } from './neomutt.js'; +import { + chatDirName, expandHome, loadState, saveState, scanDir, setFlags, stanzaFileId, writeMessage, +} from './store.js'; + +export const DEFAULTS = { + backfillDays: 30, // first time: messages from the last N days... + backfillLimit: 100, // ...and at most N per chat + fetchLimit: 50, + mediaMaxBytes: 16 * 1024 * 1024, + skipChats: [], // JIDs to ignore +}; + +const SKIP_TYPES = new Set([ + 'e2e_notification', 'notification_template', 'notification', 'gp2', 'protocol', 'ciphertext', + 'broadcast_notification', 'debug', 'unknown', 'reaction', 'revoked', +]); +const SUPPORTED_CHAT = /@(c\.us|g\.us|lid)$/; + +export function wamailConfig(account, cfg = {}) { + return { + maildir: account === 'default' ? '~/Mail/whatsapp' : `~/Mail/whatsapp-${account}`, + ...DEFAULTS, + ...cfg, + }; +} + +const flagString = (flags) => [...flags].filter((f) => f === 'S' || f === 'F').sort().join(''); + +// Message deleted for everyone: remove the local copy and do not download it again. +export function dropLocal({ account, config, stanzaId }) { + const state = loadState(account); + const fid = stanzaFileId(stanzaId); + const sm = state.msgs[fid]; + if (!sm) return false; + const dir = state.chats[sm.chat]?.dir; + const entry = dir && scanDir(path.join(expandHome(config.maildir), dir))[fid]; + if (entry) fs.rmSync(entry.path, { force: true }); + state.tombstones[fid] = sm; + delete state.msgs[fid]; + saveState(account, state); + return true; +} + +export async function syncAccount({ account, rpc, config, onlyChat, log = () => {}, now = Date.now }) { + const root = expandHome(config.maildir); + const state = loadState(account); + const nowSec = Math.floor(now() / 1000); + const skipped = (id) => !SUPPORTED_CHAT.test(id) || config.skipChats.includes(id); + const stats = { nuevos: 0, leidos: 0, noLeidos: 0, estrellas: 0, borrados: 0 }; + + // ---- chats remotos + const remote = (onlyChat ? [await rpc('chats.get', { chatId: onlyChat })] : await rpc('chats.list', {})) + .filter((c) => !skipped(c.id)); + const scope = new Set(onlyChat ? remote.map((c) => c.id) : [...remote.map((c) => c.id), ...Object.keys(state.chats)]); + try { + const me = (await rpc('auth.status', {})).me; + if (me?.id) state.me = me; + } catch { /* not critical */ } + + // ---- escaneo local + const local = {}; + const scanChat = (chatId) => { + const st = state.chats[chatId]; + if (!st?.dir) return {}; + const dir = path.join(root, st.dir); + const found = scanDir(dir); + for (const [fid, e] of Object.entries(found)) local[fid] = { ...e, chat: chatId, dir }; + return found; + }; + for (const id of scope) scanChat(id); + for (const fid of Object.keys(local)) { + if (state.tombstones[fid]) { + // deleted by mistake (e.g. a race with neomutt): the file is still there + state.msgs[fid] = state.tombstones[fid]; + delete state.tombstones[fid]; + } + } + + // ---- push: cambios locales -> WhatsApp + const failed = new Set(); + const touched = new Map(); // chatId -> 'read' | 'unread' + const perChat = new Map(); + for (const [fid, sm] of Object.entries(state.msgs)) { + if (!scope.has(sm.chat)) continue; + let loc = local[fid]; + if (!loc) { + loc = scanChat(sm.chat)[fid] && local[fid]; // second attempt before treating it as deleted + } + if (!loc || loc.flags.has('T')) { + if (loc) fs.rmSync(loc.path, { force: true }); + state.tombstones[fid] = sm; + delete state.msgs[fid]; + delete local[fid]; + stats.borrados++; + continue; + } + const agg = perChat.get(sm.chat) ?? { had: 0, now: 0, becameUnread: false }; + perChat.set(sm.chat, agg); + const wasSeen = sm.fl.includes('S'); + const isSeen = loc.flags.has('S'); + if (!sm.fromMe) { + if (!wasSeen) agg.had++; + if (!isSeen) agg.now++; + if (wasSeen && !isSeen) agg.becameUnread = true; + } + if (sm.fl.includes('F') !== loc.flags.has('F')) { + try { + await rpc(loc.flags.has('F') ? 'messages.star' : 'messages.unstar', { messageId: sm.id }); + stats.estrellas++; + } catch (e) { + log(`could not change the star of ${fid}: ${e.message}`); + failed.add(fid); + } + } + } + for (const [chatId, agg] of perChat) { + const name = state.chats[chatId]?.name ?? chatId; + try { + if (agg.becameUnread) { + await rpc('chats.unread', { chatId }); + touched.set(chatId, 'unread'); + stats.noLeidos++; + log(`${name}: marked as unread`); + } else if (agg.had > 0 && agg.now === 0) { + await rpc('chats.read', { chatId }); + touched.set(chatId, 'read'); + stats.leidos++; + log(`${name}: marked as read`); + } + } catch (e) { + log(`${name}: could not sync read state: ${e.message}`); + for (const [fid, sm] of Object.entries(state.msgs)) if (sm.chat === chatId) failed.add(fid); + } + } + + // ---- pull: WhatsApp -> local + const takenDirs = () => new Set(Object.values(state.chats).map((c) => c.dir).filter(Boolean)); + for (const c of remote) { + const isNew = !state.chats[c.id]; + const st = (state.chats[c.id] ||= { dir: null, lastTs: 0, unread: null }); + Object.assign(st, { name: c.name, isGroup: c.isGroup, archived: c.archived, pinned: c.pinned, timestamp: c.timestamp }); + + let msgs = []; + try { + if (isNew || !st.lastTs) { + const cutoff = nowSec - config.backfillDays * 86400; + if ((c.timestamp ?? 0) >= cutoff) { + msgs = (await rpc('messages.list', { chatId: c.id, limit: config.backfillLimit })) + .filter((m) => m.timestamp >= cutoff); + } + } else if ((c.timestamp ?? 0) > st.lastTs || onlyChat) { + msgs = await rpc('messages.list', { chatId: c.id, since: st.lastTs, limit: config.fetchLimit }); + } + } catch (e) { + log(`${c.name}: could not fetch messages: ${e.message}`); + continue; + } + + let wrote = 0; + let maxTs = st.lastTs; + for (const m of msgs.sort((a, b) => a.timestamp - b.timestamp)) { + maxTs = Math.max(maxTs, m.timestamp); + const fid = stanzaFileId(m.stanzaId); + if (local[fid] && !state.msgs[fid]) { + // lost or incomplete state: rebuild the entry from the file + state.msgs[fid] = { chat: c.id, id: m.id, ts: m.timestamp, fromMe: m.fromMe, fl: flagString(local[fid].flags) }; + } + if (state.msgs[fid] || state.tombstones[fid] || SKIP_TYPES.has(m.type) || m.isStatus) continue; + + st.dir ||= chatDirName(c.id, c.name, takenDirs()); + const dir = path.join(root, st.dir); + let full = m; + if (m.hasMedia && (m.media?.size == null || m.media.size <= config.mediaMaxBytes)) { + try { + full = await rpc('messages.get', { messageId: m.id, includeMedia: true }); + } catch (e) { + log(`${c.name}: could not download the media of ${fid}: ${e.message}`); + } + } + const raw = messageToEml({ ...full, chatName: full.chatName || c.name }); + const entry = writeMessage(dir, { fileId: fid, ts: m.timestamp, raw, seen: m.fromMe }); + local[fid] = { ...entry, chat: c.id, dir }; + state.msgs[fid] = { chat: c.id, id: m.id, ts: m.timestamp, fromMe: m.fromMe, fl: m.fromMe ? 'S' : '' }; + wrote++; + } + if (wrote) { + stats.nuevos += wrote; + log(`${c.name}: ${wrote} new message(s)`); + } + st.lastTs = Math.max(maxTs, c.timestamp ?? 0); + + // Read state from WhatsApp: only if the remote counter changed or messages arrived, so + // manual changes are not overwritten (WhatsApp has no "partially read"). + const unread = c.unreadCount ?? 0; + if (touched.has(c.id)) { + st.unread = touched.get(c.id) === 'read' ? 0 : -1; + } else if (st.dir && (wrote || st.unread !== unread)) { + const n = unread < 0 ? 1 : unread; + const incoming = Object.entries(local) + .filter(([fid, l]) => l.chat === c.id && state.msgs[fid] && !state.msgs[fid].fromMe) + .sort((a, b) => a[1].ts - b[1].ts); + incoming.forEach(([fid, l], i) => { + const seen = i < incoming.length - n; + if (seen === l.flags.has('S')) return; + const flags = new Set(l.flags); + if (seen) flags.add('S'); + else flags.delete('S'); + local[fid] = { ...l, ...setFlags(l.dir, fid, l, flags) }; + }); + st.unread = unread; + } + } + + // ---- save: current flags (except those that could not be pushed, to retry) + for (const [fid, l] of Object.entries(local)) { + const sm = state.msgs[fid]; + if (sm && !failed.has(fid)) sm.fl = flagString(l.flags); + } + writeMailboxes(account, root, state); + saveState(account, state); + return stats; +} |