aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--LICENSE21
-rw-r--r--README.es.md255
-rw-r--r--README.md254
-rwxr-xr-xbin/wa.js5
-rwxr-xr-xbin/wamail.js216
-rwxr-xr-xcontrib/hooks/wamail16
-rw-r--r--contrib/neomutt/wa-default.muttrc36
-rw-r--r--package-lock.json2302
-rw-r--r--package.json28
-rw-r--r--src/api/chats.js85
-rw-r--r--src/api/contacts.js86
-rw-r--r--src/api/index.js111
-rw-r--r--src/api/messages.js255
-rw-r--r--src/api/session.js77
-rw-r--r--src/cli.js379
-rw-r--r--src/compat.js94
-rw-r--r--src/config.js82
-rw-r--r--src/daemon.js452
-rw-r--r--src/format/index.js143
-rw-r--r--src/format/mail.js198
-rw-r--r--src/format/text.js51
-rw-r--r--src/helpers.js165
-rw-r--r--src/hooks.js85
-rw-r--r--src/output.js38
-rw-r--r--src/rpc.js83
-rw-r--r--src/serialize.js158
-rw-r--r--src/session.js69
-rw-r--r--src/trace.js79
-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
-rw-r--r--systemd/wa-daemon@.service15
-rw-r--r--systemd/wamail-sync.service8
-rw-r--r--systemd/wamail-sync.timer11
-rw-r--r--test/compat.test.js25
-rw-r--r--test/fixtures.js30
-rw-r--r--test/fixtures/neomutt-adjunto.eml34
-rw-r--r--test/fixtures/neomutt-respuesta.eml16
-rw-r--r--test/format.test.js66
-rw-r--r--test/wamail-mime.test.js48
-rw-r--r--test/wamail-sync.test.js172
44 files changed, 7000 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c2658d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..acdf183
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Elvis Claros Castro
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.es.md b/README.es.md
new file mode 100644
index 0000000..34c0b82
--- /dev/null
+++ b/README.es.md
@@ -0,0 +1,255 @@
+# wa-cli
+
+[English](README.md)
+
+WhatsApp desde la terminal, con la misma forma que `gws`:
+
+```
+wa <recurso> <método> [--params JSON] [--format json|table|csv|yaml|ndjson|text|eml|mbox]
+wa +<helper> ...
+wa schema <recurso.método>
+```
+
+Usa [whatsapp-web.js](https://github.com/wwebjs/whatsapp-web.js): un Chrome headless con
+WhatsApp Web, vinculado a tu teléfono como un dispositivo más. **No es un cliente oficial**:
+WhatsApp puede bloquear cuentas que usan clientes no oficiales. Usarlo con moderación
+(nada de envíos masivos).
+
+## Instalación
+
+Conviene tener el proyecto fuera de `/tmp` (systemd-tmpfiles borra lo que no se usa en 10 días).
+
+```sh
+git clone https://git.all.ar/pub/wa-cli.git && cd wa-cli
+npm install # baja Chrome para puppeteer si no está en ~/.cache/puppeteer
+ln -s "$PWD/bin/wa.js" ~/.local/bin/wa
+
+wa auth login # QR en la terminal
+wa auth login --phone 549... # o código de 8 caracteres
+```
+
+Opcional, daemon permanente (necesario para recibir mensajes con la terminal cerrada):
+
+```sh
+cp systemd/wa-daemon@.service ~/.config/systemd/user/
+systemctl --user enable --now wa-daemon@default
+```
+
+Sin systemd, el primer comando `wa` levanta el daemon solo (`WA_CLI_AUTOSTART=0` lo desactiva;
+con systemd conviene `"autostart": false` en `config.json` para no tener dos compitiendo).
+
+## Uso
+
+```sh
+wa +triage # chats con no leídos
+wa +read "Familia" -n 50 --mark-read # conversación legible
+wa +send "Mamá" llego en 10 # por nombre exacto, número o JID
+wa +send +5491122334455 -f informe.pdf "ahí va"
+wa +reply "<messageId>" dale
+wa +watch --format text # mensajes entrantes en vivo
+wa +watch --events message_create | jq -r .data.body
+wa +download "<messageId>" -d ~/Descargas
+
+wa chats list --params '{"unread": true}' --format table
+wa messages list --params '{"chatId": "Familia", "limit": 200, "since": 1788000000}'
+wa messages search --params '{"query": "factura"}' --format table
+wa messages react --params '{"messageId": "...", "emoji": "👍"}'
+wa contacts resolve --params '{"number": "+54 9 11 2233-4455"}'
+wa schema messages.send # parámetros de un método
+wa messages send --help
+```
+
+Varias cuentas (como `gws` con varias cuentas): `-a trabajo` o `WA_CLI_ACCOUNT=trabajo`.
+Cada cuenta tiene su sesión, su socket y su daemon.
+
+Códigos de salida: `0` ok, `1` error de WhatsApp / no encontrado, `2` sin sesión,
+`3` validación, `4` daemon, `5` interno.
+
+## Arquitectura
+
+```
+wa (CLI) ──JSON por línea──▶ socket Unix ──▶ daemon ──▶ whatsapp-web.js ──▶ Chrome headless
+ │
+ ├──▶ hooks ~/.config/wa-cli/hooks/<evento>
+ └──▶ events.watch (stream NDJSON)
+```
+
+WhatsApp Web tarda 10–30 s en arrancar y solo recibe mensajes mientras está abierto, por eso
+hay un daemon por cuenta y el CLI es un cliente liviano.
+
+| Qué | Dónde |
+| ------------------- | ---------------------------------------------- |
+| Sesión (perfil) | `~/.local/share/wa-cli/<cuenta>/` |
+| Media descargada | `~/.local/share/wa-cli/<cuenta>/media/` |
+| Log (autostart) | `~/.local/state/wa-cli/<cuenta>/daemon.log` |
+| Socket | `$XDG_RUNTIME_DIR/wa-cli/<cuenta>.sock` |
+| Config y hooks | `~/.config/wa-cli/{config.json,hooks/}` |
+
+`config.json` (todo opcional):
+
+```json
+{
+ "autostart": true,
+ "readyTimeoutMs": 120000,
+ "hooks": true,
+ "puppeteer": { "headless": true, "executablePath": "/usr/bin/chromium" },
+ "accounts": { "trabajo": { "hooks": false } }
+}
+```
+
+### Código
+
+| Archivo | Rol |
+| ----------------------- | -------------------------------------------------------------------- |
+| `src/api/*.js` | Métodos `recurso.método`: parámetros, columnas y handler |
+| `src/serialize.js` | Objetos de whatsapp-web.js → JSON estable (el contrato hacia afuera) |
+| `src/daemon.js` | Sesión, socket, eventos, hooks, resolución de chats por nombre |
+| `src/cli.js` | Parser estilo gws, ayuda, `auth login`, `daemon` |
+| `src/helpers.js` | `+send`, `+read`, `+watch`, ... |
+| `src/format/` | json/table/csv/yaml/ndjson/text/eml/mbox |
+| `src/compat.js` | Parches para cambios de WhatsApp Web (ver Problemas conocidos) |
+| `src/wamail/` | Puente Maildir: sync, send (parser MIME), lista de buzones |
+
+**Agregar un método** = sumar una entrada en `src/api/<recurso>.js`. Queda disponible por el
+socket, en `wa <recurso> <método>`, en `--help` y en `wa schema`. Los parámetros de tipo `chat`
+aceptan JID, número o nombre exacto; los de tipo `path` se resuelven contra el cwd del CLI.
+
+**Agregar un formato** = una función en `FORMATS` (`src/format/index.js`).
+
+## Eventos y hooks
+
+`wa events watch --params '{"events": ["all"]}'` (o `wa +watch`) emite una línea por evento:
+
+```json
+{"event": "message", "account": "default", "time": "2026-09-16T21:30:00.000Z", "data": { ...mensaje }}
+```
+
+| Evento | `data` |
+| ---------------------------------------------------- | ------------------------------------------- |
+| `message` (entrantes), `message_create` (todos) | mensaje |
+| `message_ack` | `{message, ack}` (1 servidor, 2 entregado, 3 leído) |
+| `message_edit` | `{message, newBody, prevBody}` |
+| `message_revoke_everyone` | `{message, revoked}` |
+| `message_reaction` | `{messageId, chatId, senderId, reaction}` |
+| `unread_count`, `chat_removed` | chat |
+| `group_join`, `group_leave`, `group_update`, ... | notificación de grupo |
+| `qr`, `code`, `authenticated`, `ready`, `disconnected` | estado de la sesión |
+
+Los mismos eventos disparan **hooks**: ejecutables en `~/.config/wa-cli/hooks/<evento>`,
+`<evento>.d/*` o `all.d/*`. Reciben el evento por stdin, el nombre como `$1` y
+`WA_CLI_ACCOUNT` / `WA_CLI_SOCKET` en el entorno (pueden llamar a `wa`). Se ejecutan en orden
+por hook y se re-escanean en cada evento (no hace falta reiniciar).
+
+Un mensaje serializado:
+
+```json
+{
+ "id": "false_5491122334455@c.us_3EB0C4...", "stanzaId": "3EB0C4...",
+ "chatId": "5491122334455@c.us", "chatName": "Juan Pérez",
+ "fromMe": false, "sender": "5491122334455@c.us", "senderName": "Juan Pérez",
+ "timestamp": 1789500000, "date": "2026-09-15T19:20:00.000Z",
+ "type": "chat", "body": "hola", "hasMedia": false, "media": null,
+ "hasQuotedMsg": false, "quotedStanzaId": null, "ack": 1, ...
+}
+```
+
+## Integración con neomutt: wamail
+
+`wamail` es el puente WhatsApp ↔ Maildir, análogo a `gwsmail` para Gmail: una **carpeta por chat**
+en `~/Mail/whatsapp/`, y neomutt la lee como cualquier cuenta.
+
+```
+wa-daemon ──hook (tiempo real)──▶ wamail sync --chat X ──▶ ~/Mail/whatsapp/<chat>/{new,cur}
+ ◀──────────────────── wamail sync (timer 5 min: leídos, estrellas, borrados)
+neomutt ──sendmail──▶ wamail send ──▶ wa messages send
+```
+
+| En neomutt | En WhatsApp |
+| ------------------------------------- | -------------------------------------------------------------- |
+| carpeta del sidebar | chat (orden de WhatsApp: fijados, recientes; archivados al final) |
+| leer todos los no leídos de un chat | chat marcado como leído (confirmación de lectura) |
+| marcar un mensaje como no leído | chat marcado como no leído |
+| flag (`F`) | mensaje destacado |
+| borrar | **solo local** (no se vuelve a bajar) |
+| `r` dejando la cita (`> ...`) | respuesta citada; si borrás la cita, mensaje normal |
+| `r` / `g` en un grupo | va solo al grupo (nunca en privado al autor) |
+| adjuntos | un solo adjunto imagen/video: con el texto de caption; si no, texto + documentos |
+| `gR` / `gX` / `gA` / `gG` | reaccionar / borrar (todos o para mí) / archivar chat / sincronizar ya |
+| `Tab` en To: | buscar contactos y grupos (`wamail query`) |
+| `F6` | cambiar a la cuenta WhatsApp |
+
+Recibido en WhatsApp → mail: remitente = JID (`5491122334455@c.us`, grupos `...@g.us`),
+`Message-ID <stanzaId@wa>`, `In-Reply-To` para respuestas citadas, `X-WA-Id`/`X-WA-Chat-Id` para
+las acciones, media como adjunto (hasta `mediaMaxBytes`; si no, una nota con el comando para
+bajarla). Los mensajes borrados para todos desaparecen del Maildir (hook `message_revoke_everyone`).
+
+### Instalación
+
+```sh
+ln -s "$PWD/bin/wamail.js" ~/.local/bin/wamail
+mkdir -p ~/.config/wa-cli/hooks/all.d
+ln -s "$PWD/contrib/hooks/wamail" ~/.config/wa-cli/hooks/all.d/50-wamail
+cp systemd/wamail-sync.{service,timer} ~/.config/systemd/user/
+systemctl --user daemon-reload && systemctl --user enable --now wamail-sync.timer
+wamail sync default # primera vez: últimos 30 días, hasta 100 mensajes por chat
+
+mkdir -p ~/.config/neomutt/accounts/wa
+cp contrib/neomutt/wa-default.muttrc ~/.config/neomutt/accounts/wa/default.muttrc
+```
+
+y en la config de neomutt:
+
+```muttrc
+# neomuttrc
+folder-hook 'Mail/whatsapp' 'source ~/.config/neomutt/accounts/wa/default.muttrc'
+# keys/bind.muttrc
+macro index,pager <f6> '<enter-command>source ~/.config/neomutt/accounts/wa/default.muttrc<enter><change-folder>!<enter>' "WhatsApp (wamail)"
+# switch.muttrc (lo que la cuenta WhatsApp cambia)
+unmacro index,pager gR
+unmacro index,pager gX
+set query_command = "khard email --parsable --search-in-source-files '%s'"
+```
+
+Config opcional en `~/.config/wa-cli/config.json`:
+
+```json
+{ "wamail": { "backfillDays": 30, "backfillLimit": 100, "mediaMaxBytes": 16777216, "skipChats": ["120363...@g.us"] } }
+```
+
+Estado en `~/.local/state/wamail/<cuenta>.json` (borrarlo junto con el Maildir fuerza un backfill
+limpio); envíos en `~/.local/state/wamail/send.log`.
+
+### Exportar un chat sin sincronizar
+
+```sh
+wa +export "Familia" -n 500 --media -o /tmp/familia.mbox && neomutt -R -f /tmp/familia.mbox
+```
+
+## Problemas conocidos
+
+WhatsApp Web cambia seguido y whatsapp-web.js tarda en seguirlo. `src/compat.js` parchea en
+caliente, sin tocar `node_modules`:
+
+- **`_serialized` → `$1`** en IDs de mensaje (wwebjs#201830). Síntoma: `r: r` en `chats list`,
+ `sendMessage` devolviendo `undefined`.
+- **Re-inyección en navegaciones SPA** (wwebjs#201653, arreglado en `main`, sin release).
+- **Iframe de otro origen** (`pdf-viewer`): con site isolation es un proceso aparte y, si navega
+ mientras se registran los bindings, `exposeFunction` falla con `Target closed` y `ready` no llega
+ nunca. El daemon lanza Chrome con `--disable-site-isolation-trials`.
+
+Si igual se cuelga, el watchdog del daemon sale con código 1 (60 s en `authenticated`,
+`startTimeoutMs` en `starting`) y systemd lo relanza. Para diagnosticar: `WA_CLI_TRACE=1` (o
+`"trace": true` en `config.json`) loguea llamadas a Chrome pendientes, excepciones dentro de
+bindings y navegaciones (`journalctl --user -u wa-daemon@default`).
+
+Chrome headless usa ~1 GB (picos de 2 GB en la sincronización inicial).
+
+## Tests
+
+```sh
+npm test # formatos, parser MIME (con mails reales de neomutt), sync/send de wamail contra un WhatsApp falso
+```
+
+## Licencia
+
+MIT
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3f402a2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,254 @@
+# wa-cli
+
+[Español](README.es.md)
+
+WhatsApp from the terminal, shaped like `gws`:
+
+```
+wa <resource> <method> [--params JSON] [--format json|table|csv|yaml|ndjson|text|eml|mbox]
+wa +<helper> ...
+wa schema <resource.method>
+```
+
+It uses [whatsapp-web.js](https://github.com/wwebjs/whatsapp-web.js): a headless Chrome running
+WhatsApp Web, linked to your phone as one more device. **It is not an official client**:
+WhatsApp may ban accounts that use unofficial clients. Use it in moderation (no bulk sending).
+
+## Install
+
+Keep the project outside `/tmp` (systemd-tmpfiles deletes whatever is unused for 10 days).
+
+```sh
+git clone https://git.all.ar/pub/wa-cli.git && cd wa-cli
+npm install # downloads Chrome for puppeteer if it is not in ~/.cache/puppeteer
+ln -s "$PWD/bin/wa.js" ~/.local/bin/wa
+
+wa auth login # QR in the terminal
+wa auth login --phone 549... # or an 8-character code
+```
+
+Optional, a permanent daemon (needed to receive messages with the terminal closed):
+
+```sh
+cp systemd/wa-daemon@.service ~/.config/systemd/user/
+systemctl --user enable --now wa-daemon@default
+```
+
+Without systemd, the first `wa` command starts the daemon by itself (`WA_CLI_AUTOSTART=0`
+disables that; with systemd set `"autostart": false` in `config.json` so two do not compete).
+
+## Usage
+
+```sh
+wa +triage # chats with unread messages
+wa +read "Family" -n 50 --mark-read # readable conversation
+wa +send "Mom" there in 10 # by exact name, number or JID
+wa +send +5491122334455 -f report.pdf "here it is"
+wa +reply "<messageId>" sure
+wa +watch --format text # incoming messages, live
+wa +watch --events message_create | jq -r .data.body
+wa +download "<messageId>" -d ~/Downloads
+
+wa chats list --params '{"unread": true}' --format table
+wa messages list --params '{"chatId": "Family", "limit": 200, "since": 1788000000}'
+wa messages search --params '{"query": "invoice"}' --format table
+wa messages react --params '{"messageId": "...", "emoji": "👍"}'
+wa contacts resolve --params '{"number": "+54 9 11 2233-4455"}'
+wa schema messages.send # parameters of a method
+wa messages send --help
+```
+
+Several accounts: `-a work` or `WA_CLI_ACCOUNT=work`. Each account has its own session,
+socket and daemon.
+
+Exit codes: `0` ok, `1` WhatsApp error / not found, `2` no session, `3` validation,
+`4` daemon, `5` internal.
+
+## Architecture
+
+```
+wa (CLI) ──JSON per line──▶ Unix socket ──▶ daemon ──▶ whatsapp-web.js ──▶ headless Chrome
+ │
+ ├──▶ hooks ~/.config/wa-cli/hooks/<event>
+ └──▶ events.watch (NDJSON stream)
+```
+
+WhatsApp Web takes 10–30 s to start and only receives messages while it is open, so there is
+one daemon per account and the CLI is a thin client.
+
+| What | Where |
+| ------------------- | ---------------------------------------------- |
+| Session (profile) | `~/.local/share/wa-cli/<account>/` |
+| Downloaded media | `~/.local/share/wa-cli/<account>/media/` |
+| Log (autostart) | `~/.local/state/wa-cli/<account>/daemon.log` |
+| Socket | `$XDG_RUNTIME_DIR/wa-cli/<account>.sock` |
+| Config and hooks | `~/.config/wa-cli/{config.json,hooks/}` |
+
+`config.json` (all optional):
+
+```json
+{
+ "autostart": true,
+ "readyTimeoutMs": 120000,
+ "hooks": true,
+ "puppeteer": { "headless": true, "executablePath": "/usr/bin/chromium" },
+ "accounts": { "work": { "hooks": false } }
+}
+```
+
+### Code
+
+| File | Role |
+| ----------------------- | -------------------------------------------------------------------- |
+| `src/api/*.js` | `resource.method` methods: parameters, columns and handler |
+| `src/serialize.js` | whatsapp-web.js objects → stable JSON (the external contract) |
+| `src/daemon.js` | Session, socket, events, hooks, chat resolution by name |
+| `src/cli.js` | gws-style parser, help, `auth login`, `daemon` |
+| `src/helpers.js` | `+send`, `+read`, `+watch`, ... |
+| `src/format/` | json/table/csv/yaml/ndjson/text/eml/mbox |
+| `src/compat.js` | Patches for WhatsApp Web changes (see Known issues) |
+| `src/wamail/` | Maildir bridge: sync, send (MIME parser), mailbox list |
+
+**Adding a method** = adding an entry to `src/api/<resource>.js`. It becomes available on the
+socket, as `wa <resource> <method>`, in `--help` and in `wa schema`. `chat` parameters accept a
+JID, a number or an exact name; `path` parameters are resolved against the CLI cwd.
+
+**Adding a format** = a function in `FORMATS` (`src/format/index.js`).
+
+## Events and hooks
+
+`wa events watch --params '{"events": ["all"]}'` (or `wa +watch`) prints one line per event:
+
+```json
+{"event": "message", "account": "default", "time": "2026-09-16T21:30:00.000Z", "data": { ...message }}
+```
+
+| Event | `data` |
+| ---------------------------------------------------- | ------------------------------------------- |
+| `message` (incoming), `message_create` (all) | message |
+| `message_ack` | `{message, ack}` (1 server, 2 delivered, 3 read) |
+| `message_edit` | `{message, newBody, prevBody}` |
+| `message_revoke_everyone` | `{message, revoked}` |
+| `message_reaction` | `{messageId, chatId, senderId, reaction}` |
+| `unread_count`, `chat_removed` | chat |
+| `group_join`, `group_leave`, `group_update`, ... | group notification |
+| `qr`, `code`, `authenticated`, `ready`, `disconnected` | session state |
+
+The same events trigger **hooks**: executables in `~/.config/wa-cli/hooks/<event>`,
+`<event>.d/*` or `all.d/*`. They get the event on stdin, its name as `$1` and
+`WA_CLI_ACCOUNT` / `WA_CLI_SOCKET` in the environment (so they can call `wa`). They run in order
+per hook and are rescanned on every event (no restart needed).
+
+A serialized message:
+
+```json
+{
+ "id": "false_5491122334455@c.us_3EB0C4...", "stanzaId": "3EB0C4...",
+ "chatId": "5491122334455@c.us", "chatName": "Juan Pérez",
+ "fromMe": false, "sender": "5491122334455@c.us", "senderName": "Juan Pérez",
+ "timestamp": 1789500000, "date": "2026-09-15T19:20:00.000Z",
+ "type": "chat", "body": "hola", "hasMedia": false, "media": null,
+ "hasQuotedMsg": false, "quotedStanzaId": null, "ack": 1, ...
+}
+```
+
+## neomutt integration: wamail
+
+`wamail` is the WhatsApp ↔ Maildir bridge, like `gwsmail` for Gmail: **one folder per chat**
+in `~/Mail/whatsapp/`, and neomutt reads it like any other account.
+
+```
+wa-daemon ──hook (real time)──▶ wamail sync --chat X ──▶ ~/Mail/whatsapp/<chat>/{new,cur}
+ ◀──────────────────── wamail sync (5 min timer: read state, stars, deletions)
+neomutt ──sendmail──▶ wamail send ──▶ wa messages send
+```
+
+| In neomutt | On WhatsApp |
+| ------------------------------------- | -------------------------------------------------------------- |
+| sidebar folder | chat (WhatsApp order: pinned, recent; archived at the end) |
+| read every unread message of a chat | chat marked as read (read receipts) |
+| mark a message as unread | chat marked as unread |
+| flag (`F`) | starred message |
+| delete | **local only** (not downloaded again) |
+| `r` keeping the quote (`> ...`) | quoted reply; delete the quote for a normal message |
+| `r` / `g` in a group | goes to the group only (never privately to the author) |
+| attachments | a single image/video: with the text as caption; otherwise text + documents |
+| `gR` / `gX` / `gA` / `gG` | react / delete (everyone or me) / archive chat / sync now |
+| `Tab` in To: | search contacts and groups (`wamail query`) |
+| `F6` | switch to the WhatsApp account |
+
+Received on WhatsApp → email: sender = JID (`5491122334455@c.us`, groups `...@g.us`),
+`Message-ID <stanzaId@wa>`, `In-Reply-To` for quoted replies, `X-WA-Id`/`X-WA-Chat-Id` for
+the actions, media as an attachment (up to `mediaMaxBytes`; otherwise a note with the command to
+download it). Messages deleted for everyone disappear from the Maildir (`message_revoke_everyone` hook).
+
+### Install
+
+```sh
+ln -s "$PWD/bin/wamail.js" ~/.local/bin/wamail
+mkdir -p ~/.config/wa-cli/hooks/all.d
+ln -s "$PWD/contrib/hooks/wamail" ~/.config/wa-cli/hooks/all.d/50-wamail
+cp systemd/wamail-sync.{service,timer} ~/.config/systemd/user/
+systemctl --user daemon-reload && systemctl --user enable --now wamail-sync.timer
+wamail sync default # first time: last 30 days, up to 100 messages per chat
+
+mkdir -p ~/.config/neomutt/accounts/wa
+cp contrib/neomutt/wa-default.muttrc ~/.config/neomutt/accounts/wa/default.muttrc
+```
+
+and in the neomutt config:
+
+```muttrc
+# neomuttrc
+folder-hook 'Mail/whatsapp' 'source ~/.config/neomutt/accounts/wa/default.muttrc'
+# keys/bind.muttrc
+macro index,pager <f6> '<enter-command>source ~/.config/neomutt/accounts/wa/default.muttrc<enter><change-folder>!<enter>' "WhatsApp (wamail)"
+# switch.muttrc (what the WhatsApp account changes)
+unmacro index,pager gR
+unmacro index,pager gX
+set query_command = "khard email --parsable --search-in-source-files '%s'"
+```
+
+Optional config in `~/.config/wa-cli/config.json`:
+
+```json
+{ "wamail": { "backfillDays": 30, "backfillLimit": 100, "mediaMaxBytes": 16777216, "skipChats": ["120363...@g.us"] } }
+```
+
+State lives in `~/.local/state/wamail/<account>.json` (deleting it together with the Maildir
+forces a clean backfill); sends are logged in `~/.local/state/wamail/send.log`.
+
+### Export a chat without syncing
+
+```sh
+wa +export "Family" -n 500 --media -o /tmp/family.mbox && neomutt -R -f /tmp/family.mbox
+```
+
+## Known issues
+
+WhatsApp Web changes often and whatsapp-web.js takes a while to catch up. `src/compat.js`
+patches at runtime, without touching `node_modules`:
+
+- **`_serialized` → `$1`** in message IDs (wwebjs#201830). Symptom: `r: r` in `chats list`,
+ `sendMessage` returning `undefined`.
+- **Re-injection on SPA navigations** (wwebjs#201653, fixed on `main`, not released).
+- **Cross-origin iframe** (`pdf-viewer`): with site isolation it is a separate process and, if
+ it navigates while the bindings are registered, `exposeFunction` fails with `Target closed` and
+ `ready` never comes. The daemon launches Chrome with `--disable-site-isolation-trials`.
+
+If it still hangs, the daemon watchdog exits with code 1 (60 s in `authenticated`,
+`startTimeoutMs` in `starting`) and systemd restarts it. To diagnose: `WA_CLI_TRACE=1` (or
+`"trace": true` in `config.json`) logs pending Chrome calls, exceptions inside bindings and
+navigations (`journalctl --user -u wa-daemon@default`).
+
+Headless Chrome uses ~1 GB (2 GB peaks during the initial sync).
+
+## Tests
+
+```sh
+npm test # formats, MIME parser (with real neomutt emails), wamail sync/send against a fake WhatsApp
+```
+
+## License
+
+MIT
diff --git a/bin/wa.js b/bin/wa.js
new file mode 100755
index 0000000..e854ded
--- /dev/null
+++ b/bin/wa.js
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+import { main } from '../src/cli.js';
+
+const code = await main(process.argv.slice(2));
+if (code !== undefined) process.exitCode = code;
diff --git a/bin/wamail.js b/bin/wamail.js
new file mode 100755
index 0000000..b81673e
--- /dev/null
+++ b/bin/wamail.js
@@ -0,0 +1,216 @@
+#!/usr/bin/env node
+// wamail — bridge between WhatsApp (the wa-cli daemon) and a Maildir for neomutt, like gwsmail.
+
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { loadConfig, paths } from '../src/config.js';
+import { call } from '../src/rpc.js';
+import { parseHeaders } from '../src/wamail/mime.js';
+import { sendRaw } from '../src/wamail/send.js';
+import { STATE_DIR, withLock } from '../src/wamail/store.js';
+import { dropLocal, syncAccount, wamailConfig } from '../src/wamail/sync.js';
+
+const USAGE = `wamail — WhatsApp <-> Maildir for neomutt
+
+Usage:
+ wamail sync [account ...] [--chat CHAT] [--quiet] syncs (default: accounts with a running daemon)
+ wamail revoked <account> <stanzaId> removes the local copy of a message deleted for everyone
+ wamail send <account> [-- recipients...] sendmail: reads the RFC 822 message from stdin
+ wamail query <account> <text> neomutt query_command (contacts and groups)
+ wamail react <account> [emoji] reacts to the message on stdin (pipe-message)
+ wamail delete <account> deletes the message on stdin (asks: everyone / me)
+ wamail archive <account> archives/unarchives the chat of the message on stdin
+
+Maildir: ~/Mail/whatsapp (account "default") or ~/Mail/whatsapp-<account>, one folder per chat.
+Config: ~/.config/wa-cli/config.json -> "wamail": { maildir, backfillDays, backfillLimit, mediaMaxBytes, skipChats }
+`;
+
+const SELF = fileURLToPath(import.meta.url);
+
+const rpcFor = (account) => (method, params) => call(paths(account).socket, method, params);
+
+const logger = (account, quiet) => ({
+ info: (msg) => { if (!quiet) console.log(`wamail[${account}]: ${msg}`); },
+ error: (msg) => console.error(`wamail[${account}]: ${msg}`),
+});
+
+function runningAccounts() {
+ const dir = path.dirname(paths('default').socket);
+ try {
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.sock')).map((f) => f.slice(0, -5)).sort();
+ } catch {
+ return [];
+ }
+}
+
+function requireAccount(account) {
+ if (!account) throw new Error('missing the account (e.g. default)');
+ return account;