aboutsummaryrefslogtreecommitdiffstats
path: root/src/wamail
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
commitd80640d01eeb84907e36ff4af33add9081eda17c (patch)
tree27698b34e06e28d111c2196700015d9c82f78274 /src/wamail
downloadwa-cli-main.tar.gz
wa-cli-main.zip
wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridgeHEADmain
Diffstat (limited to 'src/wamail')
-rw-r--r--src/wamail/mime.js180
-rw-r--r--src/wamail/neomutt.js46
-rw-r--r--src/wamail/send.js118
-rw-r--r--src/wamail/store.js172
-rw-r--r--src/wamail/sync.js235
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(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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;
+}