From cd5b02a4e3ddc08e8afa4b2f27ce18f903fb8d0f Mon Sep 17 00:00:00 2001 From: Kavi Date: Fri, 1 May 2026 03:00:31 -0400 Subject: [PATCH] chore: restore kua-money-trace from Bruno (source was on Bruno, Gal copy was lost) --- .gitignore | 6 + .kua-vault.json | 4 + README.md | 163 + config/entities.json | 34 + config/mail-accounts.example.json | 38 + config/mail-accounts.json | 38 + data/assignments-v2.json | 102 + data/assignments-v3.json | 1330 ++++++++ data/assignments-v4.json | 32 + data/assignments-v5.json | 1012 ++++++ data/assignments-v6.json | 4570 +++++++++++++++++++++++++++ data/example-ledger.json | 128 + debug-labels.js | 29 + docs/architecture.md | 109 + docs/data-model.md | 122 + kua.json | 121 + list-labels.js | 13 + package-lock.json | 1126 +++++++ package.json | 30 + schema.sql | 113 + src/classifier.js | 79 + src/cli.js | 40 + src/domain.js | 133 + src/formatTree.js | 55 + src/gmailApi.js | 368 +++ src/ledgerStore.js | 26 + src/localMailImport.js | 402 +++ src/mailArchive.js | 123 + src/mailCli.js | 202 ++ src/mailConfig.js | 30 + src/mailDownloader.js | 87 + src/mboxImport.js | 56 + src/moneyGraph.js | 236 ++ src/server.js | 79 + test/fixtures/sample-bank-email.eml | 20 + test/gmailApi.test.js | 82 + test/localMailImport.test.js | 228 ++ test/mailArchive.test.js | 30 + test/moneyGraph.test.js | 117 + 39 files changed, 11513 insertions(+) create mode 100644 .gitignore create mode 100644 .kua-vault.json create mode 100644 README.md create mode 100644 config/entities.json create mode 100644 config/mail-accounts.example.json create mode 100644 config/mail-accounts.json create mode 100644 data/assignments-v2.json create mode 100644 data/assignments-v3.json create mode 100644 data/assignments-v4.json create mode 100644 data/assignments-v5.json create mode 100644 data/assignments-v6.json create mode 100644 data/example-ledger.json create mode 100644 debug-labels.js create mode 100644 docs/architecture.md create mode 100644 docs/data-model.md create mode 100644 kua.json create mode 100644 list-labels.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 schema.sql create mode 100644 src/classifier.js create mode 100644 src/cli.js create mode 100644 src/domain.js create mode 100644 src/formatTree.js create mode 100644 src/gmailApi.js create mode 100644 src/ledgerStore.js create mode 100644 src/localMailImport.js create mode 100644 src/mailArchive.js create mode 100644 src/mailCli.js create mode 100644 src/mailConfig.js create mode 100644 src/mailDownloader.js create mode 100644 src/mboxImport.js create mode 100644 src/moneyGraph.js create mode 100644 src/server.js create mode 100644 test/fixtures/sample-bank-email.eml create mode 100644 test/gmailApi.test.js create mode 100644 test/localMailImport.test.js create mode 100644 test/mailArchive.test.js create mode 100644 test/moneyGraph.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2df8299 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/mail-archive/ +data/mail-oauth/ +data/*.state.json +.env +.env.* diff --git a/.kua-vault.json b/.kua-vault.json new file mode 100644 index 0000000..f31ec5d --- /dev/null +++ b/.kua-vault.json @@ -0,0 +1,4 @@ +{ + "project": "kua-money-trace", + "include": ["shared"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fe1103 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# kua-money-trace + +Servicio separado para reconstruir el arbol de origen y destino del dinero. + +No reemplaza contabilidad ni aprobacion humana. Guarda hechos, propone enlaces y permite ver de donde vino la plata que termino financiando cada gasto. + +## Primer MVP + +- Ledger en JSON local. +- Motor de grafo con enlaces explicitos y enlaces FIFO por cuenta. +- Trazabilidad hacia atras desde cualquier movimiento, documento o evento economico. +- API HTTP minima sin dependencias externas. +- CLI para resumen y pruebas rapidas. + +## Ejecutar + +```bash +npm test +npm run summarize +npm run demo +npm run serve +``` + +Luego: + +```bash +curl http://localhost:3910/health +curl http://localhost:3910/nodes/event:black-spa-10804/origin-tree +``` + +## Descargar correos + +Primera cuenta configurada: + +```text +vjoati-gmail -> vjoati@gmail.com +kdoi-email -> kdoi@email.com +``` + +La clave no se guarda en el repo. Para Gmail se debe usar una app password/OAuth y exponerla solo en el entorno: + +```bash +export VJOATI_GMAIL_APP_PASSWORD='...' +npm run mail:dry-run +npm run mail:download -- --limit 25 --since 2025-01-01 +``` + +### Gmail API OAuth + +El camino recomendado para Gmail es OAuth con permiso solo lectura. Necesita un OAuth Client de Google Cloud +con redirect URI `http://127.0.0.1:3912/oauth2callback` y la Gmail API habilitada. + +```bash +export GOOGLE_OAUTH_CLIENT_ID='...apps.googleusercontent.com' +export GOOGLE_OAUTH_CLIENT_SECRET='...' +npm run mail:gmail-oauth +``` + +El comando imprime una URL, espera el callback local y guarda el refresh token en: + +```text +data/mail-oauth/vjoati-gmail.token.json +``` + +Luego descarga desde Google, no desde Apple Mail: + +```bash +npm run mail:gmail-download -- --limit 100 --since 2025-01-01 +``` + +El archivo queda en: + +```text +data/mail-archive/vjoati-gmail/ + raw-eml/yyyy/mm/*.eml + attachments/yyyy/mm/* + manifests/emails.ndjson +``` + +Para probar sin tocar Gmail: + +```bash +npm run mail:fixture +``` + +Si Gmail no permite app password, hay dos caminos ya soportados: + +### Apple Mail local + +Lista cuentas/carpetas locales de Mail.app: + +```bash +npm run mail:list-apple +``` + +Importa una carpeta `.mbox` local de Apple Mail: + +```bash +node src/mailCli.js import-apple-mail \ + --account vjoati-gmail \ + --source '/Users/kavi/Library/Mail/V10/.../INBOX.mbox' \ + --limit 25 +``` + +Para cuentas ya configuradas en macOS, es mejor usar el indice de Mail. Esto resuelve la cuenta desde +`~/Library/Accounts/Accounts4.sqlite`, lee `Envelope Index`, y archiva los `.emlx` locales por id de mensaje: + +```bash +node src/mailCli.js import-apple-mail-index \ + --account vjoati-gmail \ + --mailbox all \ + --since 2025-01-01 \ + --limit 100 +``` + +En Gmail, `--mailbox all` usa `[Gmail]/Todos` cuando existe y si no cae a `INBOX`. +Para la cuenta `kdoi-email`, el importador detecta la IMAP directa con mensajes y usa `INBOX`. + +Atajos: + +```bash +npm run mail:import-apple-index -- --limit 100 --since 2025-01-01 +npm run mail:import-kdoi -- --limit 100 --since 2025-01-01 +``` + +### Google Takeout / MBOX + +Exporta Gmail desde Google Takeout como archivo `.mbox`, luego: + +```bash +node src/mailCli.js import-mbox \ + --account vjoati-gmail \ + --file '/ruta/al/archivo.mbox' \ + --limit 1000 +``` + +## Idea central + +Un pago con tarjeta no es el origen final. El arbol correcto puede ser: + +```text +DTE / gasto Muralla +└─ cargo Visa Darwin + └─ pago Visa desde cuenta corriente Darwin + └─ ingreso puro Darwin +``` + +Y para cuentas europeas: + +```text +Gasto con debito Revolut +└─ saldo Revolut EUR + └─ carga Revolut con Visa Chile + └─ pago Visa desde cuenta corriente Chile + └─ ingreso puro +``` + +## Proximos pasos + +- Importadores para cartolas Banco de Chile, Santander, Revolut, Mercado Pago y SII RCV. +- Archivo de correos y adjuntos en Storagebox. +- Extraccion IA de PDFs/correos con estado `propuesto`, nunca aprobado automaticamente. +- Persistencia Postgres con auditoria de decisiones. diff --git a/config/entities.json b/config/entities.json new file mode 100644 index 0000000..9be4dbe --- /dev/null +++ b/config/entities.json @@ -0,0 +1,34 @@ +{ + "entities": [ + { + "id": "vicente", + "name": "Vicente Tirado", + "kind": "person", + "rut": "18.393.009-5" + }, + { + "id": "darwin", + "name": "Darwin Bruna", + "kind": "person", + "rut": "17.194.206-3" + }, + { + "id": "muralla", + "name": "Muralla SpA", + "kind": "company", + "rut": "78.188.363-8" + }, + { + "id": "murallita", + "name": "Vicente Tirado Alimentos y Bebidas", + "kind": "company", + "rut": "78.225.723-4" + }, + { + "id": "kua", + "name": "Kua", + "kind": "company", + "rut": "78.230.716-9" + } + ] +} diff --git a/config/mail-accounts.example.json b/config/mail-accounts.example.json new file mode 100644 index 0000000..5f2486f --- /dev/null +++ b/config/mail-accounts.example.json @@ -0,0 +1,38 @@ +{ + "accounts": [ + { + "id": "vjoati-gmail", + "email": "vjoati@gmail.com", + "provider": "gmail", + "host": "imap.gmail.com", + "port": 993, + "secure": true, + "auth": { + "user": "vjoati@gmail.com", + "passwordEnv": "VJOATI_GMAIL_APP_PASSWORD" + }, + "mailboxes": [ + "INBOX" + ], + "defaultSince": "2025-01-01", + "oauth": { + "clientIdEnv": "GOOGLE_OAUTH_CLIENT_ID", + "clientSecretEnv": "GOOGLE_OAUTH_CLIENT_SECRET", + "redirectUri": "http://127.0.0.1:3912/oauth2callback", + "tokenPath": "data/mail-oauth/vjoati-gmail.token.json" + }, + "archive": { + "root": "data/mail-archive/vjoati-gmail" + } + }, + { + "id": "kdoi-email", + "email": "kdoi@email.com", + "provider": "apple-mail-local", + "defaultSince": "2025-01-01", + "archive": { + "root": "data/mail-archive/kdoi-email" + } + } + ] +} diff --git a/config/mail-accounts.json b/config/mail-accounts.json new file mode 100644 index 0000000..5f2486f --- /dev/null +++ b/config/mail-accounts.json @@ -0,0 +1,38 @@ +{ + "accounts": [ + { + "id": "vjoati-gmail", + "email": "vjoati@gmail.com", + "provider": "gmail", + "host": "imap.gmail.com", + "port": 993, + "secure": true, + "auth": { + "user": "vjoati@gmail.com", + "passwordEnv": "VJOATI_GMAIL_APP_PASSWORD" + }, + "mailboxes": [ + "INBOX" + ], + "defaultSince": "2025-01-01", + "oauth": { + "clientIdEnv": "GOOGLE_OAUTH_CLIENT_ID", + "clientSecretEnv": "GOOGLE_OAUTH_CLIENT_SECRET", + "redirectUri": "http://127.0.0.1:3912/oauth2callback", + "tokenPath": "data/mail-oauth/vjoati-gmail.token.json" + }, + "archive": { + "root": "data/mail-archive/vjoati-gmail" + } + }, + { + "id": "kdoi-email", + "email": "kdoi@email.com", + "provider": "apple-mail-local", + "defaultSince": "2025-01-01", + "archive": { + "root": "data/mail-archive/kdoi-email" + } + } + ] +} diff --git a/data/assignments-v2.json b/data/assignments-v2.json new file mode 100644 index 0000000..ce83835 --- /dev/null +++ b/data/assignments-v2.json @@ -0,0 +1,102 @@ +[ + {"id":"1d1a3b94285d4817","labels":["Triage/Personal","Categoría/Familia","Idioma/Inglés"]}, + {"id":"19dd817da6a4a375","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd80130bd41d0d","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd7f34f725b4c7","labels":["Triage/Promoción","Categoría/Noticias","Idioma/Inglés"]}, + {"id":"19dd7f185bf0a77e","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd7da0b1390b9f","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd7b1cef8d92c4","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd7a98943b8915","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Español"]}, + {"id":"19dd7660a6e81e0a","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd743ef60a213a","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Español"]}, + {"id":"19dd6aa226bc6fb2","labels":["Triage/Promoción","Categoría/Cultura","Idioma/Español"]}, + {"id":"19dd6a442e9459c3","labels":["Triage/Personal","Categoría/Trabajo","Idioma/Español"]}, + {"id":"19dd6a0dbccfdf72","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Portugués"]}, + {"id":"19dd68f20d65ae9c","labels":["Triage/Importante","Categoría/Servicios","Idioma/Inglés"]}, + {"id":"19dd66ff283871d7","labels":["Triage/Importante","Categoría/Salud","Idioma/Español"]}, + {"id":"19dd66fa85bd71af","labels":["Triage/Importante","Categoría/Bancos","Idioma/Alemán","Instrumento/Tarjeta de Crédito","Banco/Barclays"]}, + {"id":"19dd668fea307df8","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Español"]}, + {"id":"19dd6569ae9da11b","labels":["Triage/Prob Basura","Categoría/Otro","Idioma/Inglés"]}, + {"id":"19dd6558d050b1f8","labels":["Triage/Prob Basura","Categoría/Otro","Idioma/Inglés"]}, + {"id":"19dd64a24e68a6fe","labels":["Triage/Importante","Categoría/Salud","Idioma/Español"]}, + {"id":"19dd629a0d2bc65d","labels":["Triage/Promoción","Categoría/Comida","Idioma/Español"]}, + {"id":"19dd61e5108deafd","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Español"]}, + {"id":"19dd61dbab965d08","labels":["Triage/Prob Basura","Categoría/Otro","Idioma/Inglés"]}, + {"id":"19dd600007b7f8dc","labels":["Triage/Promoción","Categoría/Noticias","Idioma/Inglés"]}, + {"id":"19dd5f99b9b23450","labels":["Triage/Promoción","Categoría/Cultura","Idioma/Inglés"]}, + {"id":"19dd5f5928f399ad","labels":["Triage/Promoción","Categoría/Educación","Idioma/Portugués"]}, + {"id":"19dd5f24c2cc7f4e","labels":["Triage/Promoción","Categoría/Noticias","Idioma/Inglés"]}, + {"id":"19dd5eba8609ba87","labels":["Triage/Promoción","Categoría/Cultura","Idioma/Inglés"]}, + {"id":"19dd5ea311507a9c","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Inglés"]}, + {"id":"19dd5ddf30e936d7","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Español"]}, + {"id":"19dd5ddba592dc9d","labels":["Triage/Importante","Categoría/Bancos","Idioma/Español","Instrumento/Tarjeta de Débito","Banco/MACH"]}, + {"id":"19dd5cd27e750b3b","labels":["Triage/Personal","Categoría/Salud","Idioma/Español"]}, + {"id":"19dd5c923b21b408","labels":["Triage/Promoción","Categoría/Noticias","Idioma/Inglés"]}, + {"id":"19dd5b9ab59cc4fb","labels":["Triage/Importante","Categoría/Servicios","Idioma/Español"]}, + {"id":"19dd5b163e9b5eef","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd5b0d7f90e34f","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd596ef6a7ef8b","labels":["Triage/Prob Basura","Categoría/Comida","Idioma/Inglés"]}, + {"id":"19dd5932a834e279","labels":["Triage/Promoción","Categoría/Educación","Idioma/Inglés"]}, + {"id":"19dd577597a7f388","labels":["Triage/Promoción","Categoría/Noticias","Idioma/Inglés"]}, + {"id":"19dd56f69dbc6d2d","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Español"]}, + {"id":"19dd567f2da117a3","labels":["Triage/Promoción","Categoría/Cultura","Idioma/Inglés"]}, + {"id":"19dd5670d521d7e6","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Español"]}, + {"id":"19dd55787be27824","labels":["Triage/Promoción","Categoría/Otro","Idioma/Inglés"]}, + {"id":"19dd54756a0d843b","labels":["Triage/Prob Basura","Categoría/Trabajo","Idioma/Inglés"]}, + {"id":"19dd5455f3c7898c","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd544923501f0b","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd5410e3586e5b","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Portugués"]}, + {"id":"19dd53e22e296641","labels":["Triage/Promoción","Categoría/Otro","Idioma/Alemán"]}, + {"id":"19dd52c9c05b0fee","labels":["Triage/Promoción","Categoría/Educación","Idioma/Español"]}, + {"id":"19dd5298181b3d57","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd522318505d5f","labels":["Triage/Promoción","Categoría/Bancos","Idioma/Español","Banco/MACH"]}, + {"id":"19dd516a03e49bec","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd50a08a723f19","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Portugués"]}, + {"id":"19dd4ffa85f3b427","labels":["Triage/Importante","Categoría/Bancos","Idioma/Español","Instrumento/Tarjeta de Crédito","Banco/BCI"]}, + {"id":"19dd4f6f1cb003fc","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Portugués"]}, + {"id":"19dd4ed6a63c95cf","labels":["Triage/Promoción","Categoría/Salud","Idioma/Español"]}, + {"id":"19dd4e587a9d6084","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Español"]}, + {"id":"19dd4d88343f74bb","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd4d71f3110e75","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd4d6b90b7ba59","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd4c418b101f46","labels":["Triage/Promoción","Categoría/Cultura","Idioma/Español"]}, + {"id":"19dd4a9838bbb685","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd4a77cfaea3aa","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd4a437b882b28","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Español"]}, + {"id":"19dd4a188a42cf0d","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Portugués"]}, + {"id":"19dd49ed235c77e9","labels":["Triage/Promoción","Categoría/Bancos","Idioma/Inglés","Banco/Revolut"]}, + {"id":"19dd4949043d3ad7","labels":["Triage/Promoción","Categoría/Salud","Idioma/Español"]}, + {"id":"19dd48f42a73d77c","labels":["Triage/Promoción","Categoría/Comida","Idioma/Español"]}, + {"id":"19dd48c8197f1688","labels":["Triage/Promoción","Categoría/Comida","Idioma/Español"]}, + {"id":"19dd47dcb3d663db","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd476ca39a9236","labels":["Triage/Promoción","Categoría/Cultura","Idioma/Español"]}, + {"id":"19dd46710a0a559c","labels":["Triage/Promoción","Categoría/Noticias","Idioma/Inglés"]}, + {"id":"19dd44f6a53e6781","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Inglés"]}, + {"id":"19dd442fe209af0b","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd44074dde40e3","labels":["Triage/Promoción","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd4382b75fc5ec","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd437f1417ddaa","labels":["Triage/Promoción","Categoría/Educación","Idioma/Español"]}, + {"id":"19dd4307a78a1e3f","labels":["Triage/Prob Basura","Categoría/Comida","Idioma/Inglés"]}, + {"id":"19dd4302894fbec4","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd42e531dfcbe5","labels":["Triage/Promoción","Categoría/Transporte","Idioma/Alemán"]}, + {"id":"19dd417818f7c530","labels":["Triage/Promoción","Categoría/Salud","Idioma/Alemán"]}, + {"id":"19dd400bb62ed65e","labels":["Triage/Promoción","Categoría/Salud","Idioma/Español"]}, + {"id":"19dd3daa0f073a6f","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd3d80eadd391c","labels":["Triage/Promoción","Categoría/Educación","Idioma/Inglés"]}, + {"id":"19dd3cc1a7048946","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Francés"]}, + {"id":"19dd3c1c2ed21a75","labels":["Triage/Promoción","Categoría/Noticias","Idioma/Inglés"]}, + {"id":"19dd3bf35b58b880","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd3a763400feb1","labels":["Triage/Importante","Categoría/Bancos","Idioma/Inglés","Instrumento/Cuenta Vista","Banco/Revolut"]}, + {"id":"19dd3a0b9e5dc517","labels":["Triage/Importante","Categoría/Servicios","Idioma/Español","Banco/MercadoPago"]}, + {"id":"19dd39d939ddcb17","labels":["Triage/Prob Basura","Categoría/Viajes","Idioma/Alemán"]}, + {"id":"19dd38cc0dddbacb","labels":["Triage/Importante","Categoría/Bancos","Idioma/Español","Banco/Tenpo"]}, + {"id":"19dd33e47e4ac97b","labels":["Triage/Promoción","Categoría/Viajes","Idioma/Alemán"]}, + {"id":"19dd33ba26d6c094","labels":["Triage/Importante","Categoría/Bancos","Idioma/Español","Instrumento/Tarjeta de Débito","Banco/CopecPay"]}, + {"id":"19dd335e52ba9b9c","labels":["Triage/Promoción","Categoría/Viajes","Idioma/Inglés"]}, + {"id":"19dd329c7ff21ae7","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd31b9022bb76d","labels":["Triage/Prob Basura","Categoría/Salud","Idioma/Inglés"]}, + {"id":"19dd31aed50b4458","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd31a95cb226a8","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Alemán"]}, + {"id":"19dd30c92829e557","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]}, + {"id":"19dd30c8141508e0","labels":["Triage/Prob Basura","Categoría/Compras","Idioma/Inglés"]} +] diff --git a/data/assignments-v3.json b/data/assignments-v3.json new file mode 100644 index 0000000..f26dc59 --- /dev/null +++ b/data/assignments-v3.json @@ -0,0 +1,1330 @@ +[ + { + "id": "1d1a3b94285d4817", + "labels": [ + "! Personal", + "Familia", + "Inglés" + ], + "removeLabels": [ + "Triage/Personal", + "Categoría/Familia", + "Idioma/Inglés" + ] + }, + { + "id": "19dd817da6a4a375", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd80130bd41d0d", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd7f34f725b4c7", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Noticias", + "Idioma/Inglés" + ] + }, + { + "id": "19dd7f185bf0a77e", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd7da0b1390b9f", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd7b1cef8d92c4", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd7a98943b8915", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Español" + ] + }, + { + "id": "19dd7660a6e81e0a", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd743ef60a213a", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Español" + ] + }, + { + "id": "19dd6aa226bc6fb2", + "labels": [ + "! Promoción", + "Cultura", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Cultura", + "Idioma/Español" + ] + }, + { + "id": "19dd6a442e9459c3", + "labels": [ + "! Personal", + "Trabajo", + "Español" + ], + "removeLabels": [ + "Triage/Personal", + "Categoría/Trabajo", + "Idioma/Español" + ] + }, + { + "id": "19dd6a0dbccfdf72", + "labels": [ + "! Prob Basura", + "Viajes", + "Portugués" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Portugués" + ] + }, + { + "id": "19dd68f20d65ae9c", + "labels": [ + "! Importante", + "Servicios", + "Inglés" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Servicios", + "Idioma/Inglés" + ] + }, + { + "id": "19dd66ff283871d7", + "labels": [ + "! Importante", + "Salud", + "Español" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Salud", + "Idioma/Español" + ] + }, + { + "id": "19dd66fa85bd71af", + "labels": [ + "! Importante", + "Bancos", + "Alemán", + "Tarjeta de Crédito", + "Barclays" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Bancos", + "Idioma/Alemán", + "Instrumento/Tarjeta de Crédito", + "Banco/Barclays" + ] + }, + { + "id": "19dd668fea307df8", + "labels": [ + "! Prob Basura", + "Viajes", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Español" + ] + }, + { + "id": "19dd6569ae9da11b", + "labels": [ + "! Prob Basura", + "Otro", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Otro", + "Idioma/Inglés" + ] + }, + { + "id": "19dd6558d050b1f8", + "labels": [ + "! Prob Basura", + "Otro", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Otro", + "Idioma/Inglés" + ] + }, + { + "id": "19dd64a24e68a6fe", + "labels": [ + "! Importante", + "Salud", + "Español" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Salud", + "Idioma/Español" + ] + }, + { + "id": "19dd629a0d2bc65d", + "labels": [ + "! Promoción", + "Comida", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Comida", + "Idioma/Español" + ] + }, + { + "id": "19dd61e5108deafd", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Español" + ] + }, + { + "id": "19dd61dbab965d08", + "labels": [ + "! Prob Basura", + "Otro", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Otro", + "Idioma/Inglés" + ] + }, + { + "id": "19dd600007b7f8dc", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Noticias", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5f99b9b23450", + "labels": [ + "! Promoción", + "Cultura", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Cultura", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5f5928f399ad", + "labels": [ + "! Promoción", + "Educación", + "Portugués" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Educación", + "Idioma/Portugués" + ] + }, + { + "id": "19dd5f24c2cc7f4e", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Noticias", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5eba8609ba87", + "labels": [ + "! Promoción", + "Cultura", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Cultura", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5ea311507a9c", + "labels": [ + "! Prob Basura", + "Viajes", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5ddf30e936d7", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Español" + ] + }, + { + "id": "19dd5ddba592dc9d", + "labels": [ + "! Importante", + "Bancos", + "Español", + "Tarjeta de Débito", + "MACH" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Bancos", + "Idioma/Español", + "Instrumento/Tarjeta de Débito", + "Banco/MACH" + ] + }, + { + "id": "19dd5cd27e750b3b", + "labels": [ + "! Personal", + "Salud", + "Español" + ], + "removeLabels": [ + "Triage/Personal", + "Categoría/Salud", + "Idioma/Español" + ] + }, + { + "id": "19dd5c923b21b408", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Noticias", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5b9ab59cc4fb", + "labels": [ + "! Importante", + "Servicios", + "Español" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Servicios", + "Idioma/Español" + ] + }, + { + "id": "19dd5b163e9b5eef", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5b0d7f90e34f", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd596ef6a7ef8b", + "labels": [ + "! Prob Basura", + "Comida", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Comida", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5932a834e279", + "labels": [ + "! Promoción", + "Educación", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Educación", + "Idioma/Inglés" + ] + }, + { + "id": "19dd577597a7f388", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Noticias", + "Idioma/Inglés" + ] + }, + { + "id": "19dd56f69dbc6d2d", + "labels": [ + "! Prob Basura", + "Viajes", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Español" + ] + }, + { + "id": "19dd567f2da117a3", + "labels": [ + "! Promoción", + "Cultura", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Cultura", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5670d521d7e6", + "labels": [ + "! Prob Basura", + "Viajes", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Español" + ] + }, + { + "id": "19dd55787be27824", + "labels": [ + "! Promoción", + "Otro", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Otro", + "Idioma/Inglés" + ] + }, + { + "id": "19dd54756a0d843b", + "labels": [ + "! Prob Basura", + "Trabajo", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Trabajo", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5455f3c7898c", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd544923501f0b", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd5410e3586e5b", + "labels": [ + "! Prob Basura", + "Viajes", + "Portugués" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Portugués" + ] + }, + { + "id": "19dd53e22e296641", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Otro", + "Idioma/Alemán" + ] + }, + { + "id": "19dd52c9c05b0fee", + "labels": [ + "! Promoción", + "Educación", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Educación", + "Idioma/Español" + ] + }, + { + "id": "19dd5298181b3d57", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd522318505d5f", + "labels": [ + "! Promoción", + "Bancos", + "Español", + "MACH" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Bancos", + "Idioma/Español", + "Banco/MACH" + ] + }, + { + "id": "19dd516a03e49bec", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd50a08a723f19", + "labels": [ + "! Prob Basura", + "Viajes", + "Portugués" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Portugués" + ] + }, + { + "id": "19dd4ffa85f3b427", + "labels": [ + "! Importante", + "Bancos", + "Español", + "Tarjeta de Crédito", + "BCI" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Bancos", + "Idioma/Español", + "Instrumento/Tarjeta de Crédito", + "Banco/BCI" + ] + }, + { + "id": "19dd4f6f1cb003fc", + "labels": [ + "! Prob Basura", + "Viajes", + "Portugués" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Portugués" + ] + }, + { + "id": "19dd4ed6a63c95cf", + "labels": [ + "! Promoción", + "Salud", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Salud", + "Idioma/Español" + ] + }, + { + "id": "19dd4e587a9d6084", + "labels": [ + "! Prob Basura", + "Viajes", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Español" + ] + }, + { + "id": "19dd4d88343f74bb", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd4d71f3110e75", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd4d6b90b7ba59", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd4c418b101f46", + "labels": [ + "! Promoción", + "Cultura", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Cultura", + "Idioma/Español" + ] + }, + { + "id": "19dd4a9838bbb685", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd4a77cfaea3aa", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd4a437b882b28", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Español" + ] + }, + { + "id": "19dd4a188a42cf0d", + "labels": [ + "! Prob Basura", + "Viajes", + "Portugués" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Portugués" + ] + }, + { + "id": "19dd49ed235c77e9", + "labels": [ + "! Promoción", + "Bancos", + "Inglés", + "Revolut" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Bancos", + "Idioma/Inglés", + "Banco/Revolut" + ] + }, + { + "id": "19dd4949043d3ad7", + "labels": [ + "! Promoción", + "Salud", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Salud", + "Idioma/Español" + ] + }, + { + "id": "19dd48f42a73d77c", + "labels": [ + "! Promoción", + "Comida", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Comida", + "Idioma/Español" + ] + }, + { + "id": "19dd48c8197f1688", + "labels": [ + "! Promoción", + "Comida", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Comida", + "Idioma/Español" + ] + }, + { + "id": "19dd47dcb3d663db", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd476ca39a9236", + "labels": [ + "! Promoción", + "Cultura", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Cultura", + "Idioma/Español" + ] + }, + { + "id": "19dd46710a0a559c", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Noticias", + "Idioma/Inglés" + ] + }, + { + "id": "19dd44f6a53e6781", + "labels": [ + "! Prob Basura", + "Viajes", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Inglés" + ] + }, + { + "id": "19dd442fe209af0b", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd44074dde40e3", + "labels": [ + "! Promoción", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd4382b75fc5ec", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd437f1417ddaa", + "labels": [ + "! Promoción", + "Educación", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Educación", + "Idioma/Español" + ] + }, + { + "id": "19dd4307a78a1e3f", + "labels": [ + "! Prob Basura", + "Comida", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Comida", + "Idioma/Inglés" + ] + }, + { + "id": "19dd4302894fbec4", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd42e531dfcbe5", + "labels": [ + "! Promoción", + "Transporte", + "Alemán" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Transporte", + "Idioma/Alemán" + ] + }, + { + "id": "19dd417818f7c530", + "labels": [ + "! Promoción", + "Salud", + "Alemán" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Salud", + "Idioma/Alemán" + ] + }, + { + "id": "19dd400bb62ed65e", + "labels": [ + "! Promoción", + "Salud", + "Español" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Salud", + "Idioma/Español" + ] + }, + { + "id": "19dd3daa0f073a6f", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd3d80eadd391c", + "labels": [ + "! Promoción", + "Educación", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Educación", + "Idioma/Inglés" + ] + }, + { + "id": "19dd3cc1a7048946", + "labels": [ + "! Prob Basura", + "Compras", + "Francés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Francés" + ] + }, + { + "id": "19dd3c1c2ed21a75", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Noticias", + "Idioma/Inglés" + ] + }, + { + "id": "19dd3bf35b58b880", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd3a763400feb1", + "labels": [ + "! Importante", + "Bancos", + "Inglés", + "Cuenta Vista", + "Revolut" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Bancos", + "Idioma/Inglés", + "Instrumento/Cuenta Vista", + "Banco/Revolut" + ] + }, + { + "id": "19dd3a0b9e5dc517", + "labels": [ + "! Importante", + "Servicios", + "Español", + "MercadoPago" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Servicios", + "Idioma/Español", + "Banco/MercadoPago" + ] + }, + { + "id": "19dd39d939ddcb17", + "labels": [ + "! Prob Basura", + "Viajes", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Viajes", + "Idioma/Alemán" + ] + }, + { + "id": "19dd38cc0dddbacb", + "labels": [ + "! Importante", + "Bancos", + "Español", + "Tenpo" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Bancos", + "Idioma/Español", + "Banco/Tenpo" + ] + }, + { + "id": "19dd33e47e4ac97b", + "labels": [ + "! Promoción", + "Viajes", + "Alemán" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Viajes", + "Idioma/Alemán" + ] + }, + { + "id": "19dd33ba26d6c094", + "labels": [ + "! Importante", + "Bancos", + "Español", + "Tarjeta de Débito", + "CopecPay" + ], + "removeLabels": [ + "Triage/Importante", + "Categoría/Bancos", + "Idioma/Español", + "Instrumento/Tarjeta de Débito", + "Banco/CopecPay" + ] + }, + { + "id": "19dd335e52ba9b9c", + "labels": [ + "! Promoción", + "Viajes", + "Inglés" + ], + "removeLabels": [ + "Triage/Promoción", + "Categoría/Viajes", + "Idioma/Inglés" + ] + }, + { + "id": "19dd329c7ff21ae7", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd31b9022bb76d", + "labels": [ + "! Prob Basura", + "Salud", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Salud", + "Idioma/Inglés" + ] + }, + { + "id": "19dd31aed50b4458", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd31a95cb226a8", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Alemán" + ] + }, + { + "id": "19dd30c92829e557", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + }, + { + "id": "19dd30c8141508e0", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [ + "Triage/Prob Basura", + "Categoría/Compras", + "Idioma/Inglés" + ] + } +] \ No newline at end of file diff --git a/data/assignments-v4.json b/data/assignments-v4.json new file mode 100644 index 0000000..0eb3267 --- /dev/null +++ b/data/assignments-v4.json @@ -0,0 +1,32 @@ +[ + { + "id": "19dd66fa85bd71af", + "labels": [], + "removeLabels": ["Tarjeta de Crédito"], + "note": "#16 Barclays support email — no cartola, remove instrument label" + }, + { + "id": "19dd5ddba592dc9d", + "labels": ["Comprobante"], + "removeLabels": ["Tarjeta de Débito"], + "note": "#31 MACH comprobante de pago (TOKU *VIDA CAMARA SEGUR, Visa Débito 7253)" + }, + { + "id": "19dd4ffa85f3b427", + "labels": [], + "removeLabels": ["Tarjeta de Crédito"], + "note": "#54 BCI payment reminder — no cartola, remove instrument label" + }, + { + "id": "19dd3a763400feb1", + "labels": [], + "removeLabels": ["Cuenta Vista"], + "note": "#88 Revolut statement notification — no PDF attached, remove instrument label" + }, + { + "id": "19dd33ba26d6c094", + "labels": [], + "removeLabels": ["Tarjeta de Débito"], + "note": "#93 CopecPay failed payment notification — no cartola, remove instrument label" + } +] diff --git a/data/assignments-v5.json b/data/assignments-v5.json new file mode 100644 index 0000000..1a25c43 --- /dev/null +++ b/data/assignments-v5.json @@ -0,0 +1,1012 @@ +[ + { + "id": "19dd15a0d0ffab79", + "labels": [ + "! Importante", + "Seguridad", + "Español" + ], + "removeLabels": [], + "note": "batch2 #1" + }, + { + "id": "19dd13078ecfdd02", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #2" + }, + { + "id": "19dd1142a3213fe7", + "labels": [ + "! Prob Basura", + "Viajes", + "Español" + ], + "removeLabels": [], + "note": "batch2 #3" + }, + { + "id": "19dd0fceb917af57", + "labels": [ + "! Prob Basura", + "Viajes", + "Español" + ], + "removeLabels": [], + "note": "batch2 #4" + }, + { + "id": "19dd0f8f727c4da8", + "labels": [ + "! Prob Basura", + "Salud", + "Español" + ], + "removeLabels": [], + "note": "batch2 #5" + }, + { + "id": "19dd0f685fd5579d", + "labels": [ + "! Prob Basura", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #6" + }, + { + "id": "19dd0f653a0a77d3", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [], + "note": "batch2 #7" + }, + { + "id": "19dd0ea0ebd69f49", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #8" + }, + { + "id": "19dd0cc04f0a5d30", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #9" + }, + { + "id": "19dd0bcb8dd56b99", + "labels": [ + "! Prob Basura", + "Salud", + "Español" + ], + "removeLabels": [], + "note": "batch2 #10" + }, + { + "id": "19dd0a2a7bdb8eac", + "labels": [ + "! Promoción", + "Cultura", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #11" + }, + { + "id": "19dd09841a1e76cd", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [], + "note": "batch2 #12" + }, + { + "id": "19dd089729ffb7d5", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #13" + }, + { + "id": "19dd083fe9e9f46d", + "labels": [ + "! Prob Basura", + "Bancos", + "Español", + "MACH" + ], + "removeLabels": [], + "note": "batch2 #14" + }, + { + "id": "19dd082feb26af5a", + "labels": [ + "! Importante", + "Servicios", + "Español", + "Prex" + ], + "removeLabels": [], + "note": "batch2 #15" + }, + { + "id": "19dd0612c0ca8851", + "labels": [ + "! Promoción", + "Educación", + "Español" + ], + "removeLabels": [], + "note": "batch2 #16" + }, + { + "id": "19dd051c448865f3", + "labels": [ + "! Prob Basura", + "Noticias", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #17" + }, + { + "id": "19dd033a8a7e878d", + "labels": [ + "! Importante", + "Bancos", + "Español", + "BCI" + ], + "removeLabels": [], + "note": "batch2 #18" + }, + { + "id": "19dd01f4f1e419e4", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #19" + }, + { + "id": "19dd01b1f0f7a300", + "labels": [ + "! Prob Basura", + "Salud", + "Español" + ], + "removeLabels": [], + "note": "batch2 #20" + }, + { + "id": "19dd00c776b36bff", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [], + "note": "batch2 #21" + }, + { + "id": "19dcffdf556f00d3", + "labels": [ + "! Prob Basura", + "Bancos", + "Español", + "Prex" + ], + "removeLabels": [], + "note": "batch2 #22" + }, + { + "id": "19dcfeec134a110d", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #23" + }, + { + "id": "19dcfe905b222982", + "labels": [ + "! Promoción", + "Educación", + "Español" + ], + "removeLabels": [], + "note": "batch2 #24" + }, + { + "id": "19dcfe2defd84ab9", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #25" + }, + { + "id": "19dcfdda6d89f1d8", + "labels": [ + "! Prob Basura", + "Salud", + "Portugués" + ], + "removeLabels": [], + "note": "batch2 #26" + }, + { + "id": "19dcfd853eb3dfec", + "labels": [ + "! Promoción", + "Educación", + "Español" + ], + "removeLabels": [], + "note": "batch2 #27" + }, + { + "id": "19dcfc8aeae8d4aa", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #28" + }, + { + "id": "19dcfb697d097ff5", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #29" + }, + { + "id": "19dcfb61c24247a3", + "labels": [ + "! Prob Basura", + "Viajes", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #30" + }, + { + "id": "19dcfb1934055196", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #31" + }, + { + "id": "19dcfb1271b607e4", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #32" + }, + { + "id": "19dcf8fdc1820c41", + "labels": [ + "! Prob Basura", + "Bancos", + "Español", + "Tenpo" + ], + "removeLabels": [], + "note": "batch2 #33" + }, + { + "id": "19dcf8a697987b4f", + "labels": [ + "! Prob Basura", + "Salud", + "Español" + ], + "removeLabels": [], + "note": "batch2 #34" + }, + { + "id": "19dcf80d541bd52a", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #35" + }, + { + "id": "19dcf7a224f40fda", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #36" + }, + { + "id": "19dcf72b39d3092b", + "labels": [ + "! Importante", + "Servicios", + "Español", + "Prex" + ], + "removeLabels": [], + "note": "batch2 #37" + }, + { + "id": "19dcf5431c7da3c0", + "labels": [ + "! Personal", + "Trabajo", + "Español" + ], + "removeLabels": [], + "note": "batch2 #38" + }, + { + "id": "19dcf513cbd3f8aa", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [], + "note": "batch2 #39" + }, + { + "id": "19dcf4518f4153e9", + "labels": [ + "! Prob Basura", + "Trabajo", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #40" + }, + { + "id": "19dcf41e9711cd4f", + "labels": [ + "! Prob Basura", + "Salud", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #41" + }, + { + "id": "19dcf300947ec41b", + "labels": [ + "! Prob Basura", + "Viajes", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #42" + }, + { + "id": "19dcf1eda4159298", + "labels": [ + "! Prob Basura", + "Salud", + "Español" + ], + "removeLabels": [], + "note": "batch2 #43" + }, + { + "id": "19dcf19f44accbb0", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #44" + }, + { + "id": "19dcf1808f63cf95", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #45" + }, + { + "id": "19dcf16de8016fad", + "labels": [ + "! Prob Basura", + "Bancos", + "Español", + "Tenpo" + ], + "removeLabels": [], + "note": "batch2 #46" + }, + { + "id": "19dcf0a53ac13738", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #47" + }, + { + "id": "19dced41fa8f88f3", + "labels": [ + "! Promoción", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #48" + }, + { + "id": "19dce8d9476e617a", + "labels": [ + "! Prob Basura", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #49" + }, + { + "id": "19dce8b9cd9af382", + "labels": [ + "! Promoción", + "Cultura", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #50" + }, + { + "id": "19dce7cb2f2d911b", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #51" + }, + { + "id": "19dce6f1f40ddb3e", + "labels": [ + "! Promoción", + "Educación", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #52" + }, + { + "id": "19dce66413189fdf", + "labels": [ + "! Prob Basura", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #53" + }, + { + "id": "19dce61ea7143868", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #54" + }, + { + "id": "19dce55e26da4e00", + "labels": [ + "! Prob Basura", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #55" + }, + { + "id": "19dce467a157c9a4", + "labels": [ + "! Promoción", + "Educación", + "Español" + ], + "removeLabels": [], + "note": "batch2 #56" + }, + { + "id": "19dce3626d1ef9d6", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #57" + }, + { + "id": "19dce1fdfdc6d2bc", + "labels": [ + "! Promoción", + "Salud", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #58" + }, + { + "id": "19dce0a092369597", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #59" + }, + { + "id": "19dce0038fe861f7", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #60" + }, + { + "id": "19dcdf463ef0acd4", + "labels": [ + "! Prob Basura", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #61" + }, + { + "id": "19dcdf45209e3529", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #62" + }, + { + "id": "19dcde68b21e6af7", + "labels": [ + "! Prob Basura", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #63" + }, + { + "id": "19dcddb4e0508b7e", + "labels": [ + "! Prob Basura", + "Transporte", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #64" + }, + { + "id": "19dcdd6efa40cfea", + "labels": [ + "! Personal", + "Educación", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #65" + }, + { + "id": "19dcda5831ce3862", + "labels": [ + "! Promoción", + "Noticias", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #66" + }, + { + "id": "19dcd9c95170152b", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #67" + }, + { + "id": "19dcd8ce3ad655d5", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #68" + }, + { + "id": "19dcd1c15070085c", + "labels": [ + "! Prob Basura", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #69" + }, + { + "id": "19dcc1c7aaa99b73", + "labels": [ + "! Prob Basura", + "Otro", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #70" + }, + { + "id": "19dcc0a52645e743", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #71" + }, + { + "id": "19dcc08e3df7a457", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #72" + }, + { + "id": "19dcb6ca7e164ab9", + "labels": [ + "! Promoción", + "Viajes", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #73" + }, + { + "id": "19dcb63570bb2d59", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #74" + }, + { + "id": "19dcb437e45b698b", + "labels": [ + "! Prob Basura", + "Salud", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #75" + }, + { + "id": "19dcb361b1dee776", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #76" + }, + { + "id": "19dcb1613ce26e6f", + "labels": [ + "! Prob Basura", + "Bancos", + "Español", + "Tenpo" + ], + "removeLabels": [], + "note": "batch2 #77" + }, + { + "id": "19dcaf83f83d5add", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #78" + }, + { + "id": "19dcaca0e5cbf66d", + "labels": [ + "! Prob Basura", + "Bancos", + "Español", + "BCI" + ], + "removeLabels": [], + "note": "batch2 #79" + }, + { + "id": "19dcac7324db27ca", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #80" + }, + { + "id": "19dcac543019d2c3", + "labels": [ + "! Promoción", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #81" + }, + { + "id": "19dcabe06faf93c8", + "labels": [ + "! Prob Basura", + "Viajes", + "Portugués" + ], + "removeLabels": [], + "note": "batch2 #82" + }, + { + "id": "19dcabcd4a489df6", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #83" + }, + { + "id": "19dcaaec5ba224ab", + "labels": [ + "! Prob Basura", + "Educación", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #84" + }, + { + "id": "19dcaa0cb1e9b0ff", + "labels": [ + "! Promoción", + "Cultura", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #85" + }, + { + "id": "19dca8955f9014b2", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #86" + }, + { + "id": "19dca88e3f1bc256", + "labels": [ + "! Prob Basura", + "Noticias", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #87" + }, + { + "id": "19dca884d5961491", + "labels": [ + "! Prob Basura", + "Viajes", + "Español" + ], + "removeLabels": [], + "note": "batch2 #88" + }, + { + "id": "19dca86495e20408", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #89" + }, + { + "id": "19dca7337194aa19", + "labels": [ + "! Prob Basura", + "Compras", + "Español" + ], + "removeLabels": [], + "note": "batch2 #90" + }, + { + "id": "19dca5c93792b8e6", + "labels": [ + "! Prob Basura", + "Compras", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #91" + }, + { + "id": "19dca5c5f1c9ffc3", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #92" + }, + { + "id": "19dca5c0bc7120d6", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #93" + }, + { + "id": "19dca4e048ed423f", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #94" + }, + { + "id": "19dca36351332e19", + "labels": [ + "! Promoción", + "Salud", + "Español" + ], + "removeLabels": [], + "note": "batch2 #95" + }, + { + "id": "19dca2ad3bda7cef", + "labels": [ + "! Promoción", + "Salud", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #96" + }, + { + "id": "19dca1a82719530d", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #97" + }, + { + "id": "19dca1906e3a04e7", + "labels": [ + "! Importante", + "Bancos", + "Español", + "BCI" + ], + "removeLabels": [], + "note": "batch2 #98" + }, + { + "id": "19dca0200aa93cf5", + "labels": [ + "! Prob Basura", + "Noticias", + "Alemán" + ], + "removeLabels": [], + "note": "batch2 #99" + }, + { + "id": "19dc9f23b4ad8bb5", + "labels": [ + "! Prob Basura", + "Compras", + "Inglés" + ], + "removeLabels": [], + "note": "batch2 #100" + } +] diff --git a/data/assignments-v6.json b/data/assignments-v6.json new file mode 100644 index 0000000..5f87ae3 --- /dev/null +++ b/data/assignments-v6.json @@ -0,0 +1,4570 @@ +[ + { + "id": "19b7599dfb931a0f", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 1)" + }, + { + "id": "19b75986548166f2", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 2)" + }, + { + "id": "19b7597250e9d3f2", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 3)" + }, + { + "id": "19b7593c4d5fd317", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 4)" + }, + { + "id": "19b7580749aebf7b", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 5)" + }, + { + "id": "19b7577d358195d8", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 6)" + }, + { + "id": "19b757575af730f5", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 7)" + }, + { + "id": "19b756e2c6351975", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 8)" + }, + { + "id": "19b756d2a274e138", + "labels": [ + "! Promoción", + "Español", + "Bancos", + "Banco de Chile" + ], + "note": "Fixed triage pass (N: 9)" + }, + { + "id": "19b7566c6053fb7b", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 10)" + }, + { + "id": "19b75654029c9eba", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "MACH" + ], + "note": "Fixed triage pass (N: 11)" + }, + { + "id": "19b75631c61a124c", + "labels": [ + "! Promoción", + "Español", + "Santander", + "Bancos" + ], + "note": "Fixed triage pass (N: 12)" + }, + { + "id": "19b7562a4f59af7f", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 13)" + }, + { + "id": "19b75607119cc2d8", + "labels": [ + "! Promoción", + "Loyalty", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 14)" + }, + { + "id": "19b755d714ae17ba", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 15)" + }, + { + "id": "19b7558197714e09", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 16)" + }, + { + "id": "19b7554f3b350fee", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 17)" + }, + { + "id": "19b7553721f61873", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 18)" + }, + { + "id": "19b752ffe2f2f114", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 19)" + }, + { + "id": "19b752caf41279c1", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "MACH" + ], + "note": "Fixed triage pass (N: 20)" + }, + { + "id": "19b7528474a4a530", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 21)" + }, + { + "id": "19b7526f4bb22653", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 22)" + }, + { + "id": "19b750e26c503978", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 23)" + }, + { + "id": "19b75007a5c3e8ce", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 24)" + }, + { + "id": "19b74fe079521696", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 25)" + }, + { + "id": "19b74fa69b7294e6", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 26)" + }, + { + "id": "19b74fa23075be40", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 27)" + }, + { + "id": "19b74f102613a4bb", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 28)" + }, + { + "id": "19b74ee6fb441458", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 29)" + }, + { + "id": "19b74ecba00d5f82", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 30)" + }, + { + "id": "19b74ec94f251a8e", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 31)" + }, + { + "id": "19b74e7d47c3f0a8", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 32)" + }, + { + "id": "19b74cf2ca0d882c", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 33)" + }, + { + "id": "19b74c1946b45f71", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 34)" + }, + { + "id": "19b74846430103e1", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 35)" + }, + { + "id": "19b7481418dc44b1", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 36)" + }, + { + "id": "19b74814101413aa", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 37)" + }, + { + "id": "19b747ee0bece67b", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 38)" + }, + { + "id": "19b74769f7970eed", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 39)" + }, + { + "id": "19b746b193b8dbf8", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 40)" + }, + { + "id": "19b746803a486acb", + "labels": [ + "Otro", + "! Prob Basura", + "Portugués" + ], + "note": "Fixed triage pass (N: 41)" + }, + { + "id": "19b745cb4639cbb8", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 42)" + }, + { + "id": "19b7459f424e4df1", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 43)" + }, + { + "id": "19b7448d628acaff", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 44)" + }, + { + "id": "19b742cdb7ab8c15", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 45)" + }, + { + "id": "19b74119eb71aec2", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 46)" + }, + { + "id": "19b73f5989cb472c", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 47)" + }, + { + "id": "19b73e8c90a39a29", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Revolut" + ], + "note": "Fixed triage pass (N: 48)" + }, + { + "id": "19b73a58b94f6ffe", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 49)" + }, + { + "id": "19b73a1587875b86", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 50)" + }, + { + "id": "19b737b53063aaff", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 51)" + }, + { + "id": "19b7353a7b8b1e12", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 52)" + }, + { + "id": "19b734b5a5546180", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 53)" + }, + { + "id": "19b7347b2f88568f", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 54)" + }, + { + "id": "19b73426a55a20dd", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 55)" + }, + { + "id": "19b733cd936f3d35", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 56)" + }, + { + "id": "19b7327e9784f7e9", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 57)" + }, + { + "id": "19b732758253a770", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 58)" + }, + { + "id": "19b732744caf63f4", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 59)" + }, + { + "id": "19b72f0920cebe3e", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 60)" + }, + { + "id": "19b72cd471196ce9", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 61)" + }, + { + "id": "19b72c2262b17ae1", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 62)" + }, + { + "id": "19b728d5a06b9949", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 63)" + }, + { + "id": "19b7286e8a4afb5a", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 64)" + }, + { + "id": "19b7275b296d968f", + "labels": [ + "! Promoción", + "Español", + "Otro", + "Tarjeta de Crédito" + ], + "note": "Fixed triage pass (N: 65)" + }, + { + "id": "19b7259fc7b1edd3", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 66)" + }, + { + "id": "19b71ee84b8644ab", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 67)" + }, + { + "id": "19b71c518b79ad00", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 68)" + }, + { + "id": "19b71bc6a22ed1e0", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 69)" + }, + { + "id": "19b71b8a53ccb3c5", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 70)" + }, + { + "id": "19b718eeb48b9aab", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 71)" + }, + { + "id": "19b71762af88d717", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 72)" + }, + { + "id": "19b716227a8504ca", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 73)" + }, + { + "id": "19b715ad23c9b183", + "labels": [ + "! Prob Basura", + "Español", + "Tenpo", + "Bancos" + ], + "note": "Fixed triage pass (N: 74)" + }, + { + "id": "19b7154a1e5c2fc6", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 75)" + }, + { + "id": "19b7148e917ebfc7", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 76)" + }, + { + "id": "19b7130761c9bff2", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 77)" + }, + { + "id": "19b712b45c2da5d1", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 78)" + }, + { + "id": "19b7114eaff9c824", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 79)" + }, + { + "id": "19b7113b7c7be945", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 80)" + }, + { + "id": "19b710544bef87da", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 81)" + }, + { + "id": "19b70ece7b34f943", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 82)" + }, + { + "id": "19b70e5f619ab6ac", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 83)" + }, + { + "id": "19b70d8eb8ceb90d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 84)" + }, + { + "id": "19b70b8b29bf79e8", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 85)" + }, + { + "id": "19b70a1e717811f1", + "labels": [ + "! Promoción", + "Inglés", + "Otro", + "Loyalty" + ], + "note": "Fixed triage pass (N: 86)" + }, + { + "id": "19b70956c7b74663", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 87)" + }, + { + "id": "19b70953f025090c", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 88)" + }, + { + "id": "19b7080501957983", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 89)" + }, + { + "id": "19b7076c07a506ad", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 90)" + }, + { + "id": "19b706cbdb4eb0b6", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 91)" + }, + { + "id": "19b706c2899f28e9", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 92)" + }, + { + "id": "19b7043cfd44a773", + "labels": [ + "! Prob Basura", + "Español", + "Tenpo", + "Bancos" + ], + "note": "Fixed triage pass (N: 93)" + }, + { + "id": "19b703869fc33cd8", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 94)" + }, + { + "id": "19b70367d6333934", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 95)" + }, + { + "id": "19b7036530269516", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 96)" + }, + { + "id": "19b7031a82ca4c25", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 97)" + }, + { + "id": "19b7020e033e835e", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 98)" + }, + { + "id": "19b701f27e721b96", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 99)" + }, + { + "id": "19b7002ad0186fec", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 100)" + }, + { + "id": "19b7001c2ab05815", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 101)" + }, + { + "id": "19b6fefd9d04c25e", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 102)" + }, + { + "id": "19b6fed7d8f2ee2e", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 103)" + }, + { + "id": "19b6fe8767cadcb1", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 104)" + }, + { + "id": "19b6fe7e93aa0ec5", + "labels": [ + "! Prob Basura", + "Español", + "Santander", + "Bancos" + ], + "note": "Fixed triage pass (N: 105)" + }, + { + "id": "19b6fe225322959d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 106)" + }, + { + "id": "19b6fe1bcc527c41", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 107)" + }, + { + "id": "19b6fd1950a05bfe", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 108)" + }, + { + "id": "19b6fd01ad275348", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 109)" + }, + { + "id": "19b6fce9759de009", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 110)" + }, + { + "id": "19b6fcdb3f8e4a06", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 111)" + }, + { + "id": "19b6fca22d2eeed7", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 112)" + }, + { + "id": "19b6fc77ef4a1d47", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 113)" + }, + { + "id": "19b6fc0c03e925b8", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 114)" + }, + { + "id": "19b6fc0b3f4eb699", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 115)" + }, + { + "id": "19b6fbf2d767f8df", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 116)" + }, + { + "id": "19b6fb87e213341f", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 117)" + }, + { + "id": "19b6fa6b726d430c", + "labels": [ + "! Prob Basura", + "Español", + "Global66", + "Bancos" + ], + "note": "Fixed triage pass (N: 118)" + }, + { + "id": "19b6fa39ec99f952", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 119)" + }, + { + "id": "19b6f7de70e01da5", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Revolut" + ], + "note": "Fixed triage pass (N: 120)" + }, + { + "id": "19b6f771a8697494", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 121)" + }, + { + "id": "19b6f76548b6f162", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 122)" + }, + { + "id": "19b6f5dea3c57719", + "labels": [ + "! Prob Basura", + "MACH", + "Español", + "Loyalty", + "Bancos" + ], + "note": "Fixed triage pass (N: 123)" + }, + { + "id": "19b6f5c2329c81c8", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 124)" + }, + { + "id": "19b6f5bf3d77e5e7", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 125)" + }, + { + "id": "19b6f5b1077e4b8f", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 126)" + }, + { + "id": "19b6f53233de9f95", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 127)" + }, + { + "id": "19b6f4217e2bd46d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 128)" + }, + { + "id": "19b6f3d253102a95", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 129)" + }, + { + "id": "19b6f3a7229ef5cc", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 130)" + }, + { + "id": "19b6f394b8972db8", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 131)" + }, + { + "id": "19b6f382aed26bf2", + "labels": [ + "! Promoción", + "Loyalty", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 132)" + }, + { + "id": "19b6f31fb1116689", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 133)" + }, + { + "id": "19b6f31f308870df", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 134)" + }, + { + "id": "19b6f2ff315615ac", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 135)" + }, + { + "id": "19b6f25b2cd5a639", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 136)" + }, + { + "id": "19b6f22dd3ca8ce3", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 137)" + }, + { + "id": "19b6eca264d525e8", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 138)" + }, + { + "id": "19b6ec56897dab51", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 139)" + }, + { + "id": "19b6e86712ce414c", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 140)" + }, + { + "id": "19b6e7db5de69d1e", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 141)" + }, + { + "id": "19b6e6a46be4ef2b", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 142)" + }, + { + "id": "19b6e5f9978e94d2", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 143)" + }, + { + "id": "19b6e4d495418713", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 144)" + }, + { + "id": "19b6e468a9af50f4", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 145)" + }, + { + "id": "19b6e3afa50f94fe", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 146)" + }, + { + "id": "19b6e2e2986943ef", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 147)" + }, + { + "id": "19b6e19ab895879d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 148)" + }, + { + "id": "19b6e167d229c870", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 149)" + }, + { + "id": "19b6e1040a64201b", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 150)" + }, + { + "id": "19b6e041ab4ef679", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 151)" + }, + { + "id": "19b6de08603c04dc", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 152)" + }, + { + "id": "19b6db0fcc379f41", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 153)" + }, + { + "id": "19b6d9400f10556c", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 154)" + }, + { + "id": "19b6d49bc4d4a27e", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 155)" + }, + { + "id": "19b6d37dca45807a", + "labels": [ + "Español", + "! Importante", + "Seguridad", + "Código Verificación" + ], + "note": "Fixed triage pass (N: 156)" + }, + { + "id": "19b6cfb661a61135", + "labels": [ + "Español", + "! Importante", + "Servicios", + "Servidores" + ], + "note": "Fixed triage pass (N: 157)" + }, + { + "id": "19b6cf94ff88946c", + "labels": [ + "Español", + "! Importante", + "Servicios", + "Servidores" + ], + "note": "Fixed triage pass (N: 158)" + }, + { + "id": "19b6ce10aa7e2cd3", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 159)" + }, + { + "id": "19b6c92bb22f10d5", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 160)" + }, + { + "id": "19b6c8ff9aba732a", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 161)" + }, + { + "id": "19b6c59ab7c3ca1a", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 162)" + }, + { + "id": "19b6c4ff3f364271", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 163)" + }, + { + "id": "19b6c4c61fa06d39", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 164)" + }, + { + "id": "19b6c3c32a0065b5", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 165)" + }, + { + "id": "19b6c3be6ee5cb28", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 166)" + }, + { + "id": "19b6c3bdda81b5c4", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 167)" + }, + { + "id": "19b6c293248323d0", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 168)" + }, + { + "id": "19b6c1767ec5800d", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 169)" + }, + { + "id": "19b6c04d5d58d610", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 170)" + }, + { + "id": "19b6befaee043ad7", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 171)" + }, + { + "id": "19b6beeccb8aa9f0", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 172)" + }, + { + "id": "19b6bed3ca57d2ef", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 173)" + }, + { + "id": "19b6be6c119eac1b", + "labels": [ + "Español", + "! Importante", + "Seguridad", + "Código Verificación" + ], + "note": "Fixed triage pass (N: 174)" + }, + { + "id": "19b6be353e78e31a", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 175)" + }, + { + "id": "19b6bb8eaf4fec8d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 176)" + }, + { + "id": "19b6bb38bf8d3f4f", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 177)" + }, + { + "id": "19b6b92323bdfe58", + "labels": [ + "! Promoción", + "Inglés", + "Otro", + "Loyalty" + ], + "note": "Fixed triage pass (N: 178)" + }, + { + "id": "19b6b86b05ed8e1a", + "labels": [ + "Español", + "! Importante", + "Seguridad", + "Código Verificación" + ], + "note": "Fixed triage pass (N: 179)" + }, + { + "id": "19b6b83b53c80e02", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 180)" + }, + { + "id": "19b6b7ae1071830c", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 181)" + }, + { + "id": "19b6b733ba9de0e2", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 182)" + }, + { + "id": "19b6b6e95e996b28", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 183)" + }, + { + "id": "19b6b4e9735dbf0b", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 184)" + }, + { + "id": "19b6b4c599ca9b93", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 185)" + }, + { + "id": "19b6b49bd03b9b2d", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 186)" + }, + { + "id": "19b6b48cf7d40db6", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 187)" + }, + { + "id": "19b6b412294e68ab", + "labels": [ + "! Importante", + "Seguridad", + "Código Verificación", + "Portugués" + ], + "note": "Fixed triage pass (N: 188)" + }, + { + "id": "19b6b2f28cf3dd93", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 189)" + }, + { + "id": "19b6b2a56400e2be", + "labels": [ + "! Prob Basura", + "Lider BCI", + "Español", + "Loyalty", + "Bancos" + ], + "note": "Fixed triage pass (N: 190)" + }, + { + "id": "19b6b257edc39a0f", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 191)" + }, + { + "id": "19b6b1ed8b6f19d1", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 192)" + }, + { + "id": "19b6b1e01d3507e9", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 193)" + }, + { + "id": "19b6b1d77e56b181", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 194)" + }, + { + "id": "19b6b196cb2bcb0b", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 195)" + }, + { + "id": "19b6b1294e87f92c", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 196)" + }, + { + "id": "19b6b113dd339364", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 197)" + }, + { + "id": "19b6aea93cb3532a", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 198)" + }, + { + "id": "19b6ada873ee9983", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 199)" + }, + { + "id": "19b6aca07f59beb9", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 200)" + }, + { + "id": "19b6ac753ed6f2a3", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 201)" + }, + { + "id": "19b6ac24dfd1b45d", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 202)" + }, + { + "id": "19b6abde7d2de454", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "MACH" + ], + "note": "Fixed triage pass (N: 203)" + }, + { + "id": "19b6ab409edbd95c", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 204)" + }, + { + "id": "19b6ab018430ec76", + "labels": [ + "! Promoción", + "Español", + "Lider BCI", + "Bancos" + ], + "note": "Fixed triage pass (N: 205)" + }, + { + "id": "19b6aafd4ec73d27", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 206)" + }, + { + "id": "19b6aa9762ce658e", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 207)" + }, + { + "id": "19b6aa8c46c8f991", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 208)" + }, + { + "id": "19b6aa579673769e", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 209)" + }, + { + "id": "19b6aa5740146b1f", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 210)" + }, + { + "id": "19b6aa2de1defa70", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Revolut" + ], + "note": "Fixed triage pass (N: 211)" + }, + { + "id": "19b6a9ff240bc52a", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 212)" + }, + { + "id": "19b6a9eeaf5998e3", + "labels": [ + "Lider BCI", + "Cobranza", + "Español", + "Bancos", + "! Importante" + ], + "note": "Fixed triage pass (N: 213)" + }, + { + "id": "19b6a9746a5ac25b", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 214)" + }, + { + "id": "19b6a88c539221ef", + "labels": [ + "! Promoción", + "Español", + "Santander", + "Bancos" + ], + "note": "Fixed triage pass (N: 215)" + }, + { + "id": "19b6a765d469512a", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 216)" + }, + { + "id": "19b6a69af466661b", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 217)" + }, + { + "id": "19b6a47f119251f0", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 218)" + }, + { + "id": "19b6a34f6496651e", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 219)" + }, + { + "id": "19b6a2d58bf75a7a", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 220)" + }, + { + "id": "19b6a2c0b100ae31", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 221)" + }, + { + "id": "19b6a220d0f035db", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 222)" + }, + { + "id": "19b6a1b796136b92", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 223)" + }, + { + "id": "19b6a080d44a819a", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 224)" + }, + { + "id": "19b6a0792aef9caa", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 225)" + }, + { + "id": "19b69ee56c5d911d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 226)" + }, + { + "id": "19b69e5f4e3e90d1", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 227)" + }, + { + "id": "19b69c5496b129c2", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 228)" + }, + { + "id": "19b69abeabc0b428", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 229)" + }, + { + "id": "19b69a48284a0b67", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 230)" + }, + { + "id": "19b696e05e39ee16", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 231)" + }, + { + "id": "19b6968bfdcf3004", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 232)" + }, + { + "id": "19b695392cdae9f0", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 233)" + }, + { + "id": "19b69443f6eb5642", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 234)" + }, + { + "id": "19b69410bb5874f7", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 235)" + }, + { + "id": "19b693cde34763da", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 236)" + }, + { + "id": "19b692759535b7c9", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 237)" + }, + { + "id": "19b6906fa8f8a8ec", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 238)" + }, + { + "id": "19b68ed2a36509a0", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 239)" + }, + { + "id": "19b686a0cd456691", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 240)" + }, + { + "id": "19b682e910f881a5", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 241)" + }, + { + "id": "19b67dafc3b901c0", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 242)" + }, + { + "id": "19b679fb44872a3b", + "labels": [ + "! Promoción", + "Revolut", + "Inglés", + "Bancos" + ], + "note": "Fixed triage pass (N: 243)" + }, + { + "id": "19b676c67eb3398f", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 244)" + }, + { + "id": "19b674723cefd0d7", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 245)" + }, + { + "id": "19b6740475049186", + "labels": [ + "! Prob Basura", + "Español", + "Tenpo", + "Bancos" + ], + "note": "Fixed triage pass (N: 246)" + }, + { + "id": "19b671747cded675", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 247)" + }, + { + "id": "19b66dcd710c3f92", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 248)" + }, + { + "id": "19b66c921fe04afb", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 249)" + }, + { + "id": "19b66c8298bd1fca", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 250)" + }, + { + "id": "19b66a135cf42e4b", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 251)" + }, + { + "id": "19b66985437dd086", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 252)" + }, + { + "id": "19b6696e2ed2aa73", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 253)" + }, + { + "id": "19b66726bfc18c14", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 254)" + }, + { + "id": "19b66717072f22d4", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 255)" + }, + { + "id": "19b6658a69d37be2", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 256)" + }, + { + "id": "19b6656164a896db", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 257)" + }, + { + "id": "19b665594c9e968b", + "labels": [ + "! Promoción", + "Inglés", + "Otro", + "Loyalty" + ], + "note": "Fixed triage pass (N: 258)" + }, + { + "id": "19b662e7a24397cd", + "labels": [ + "! Promoción", + "Inglés", + "Otro", + "Loyalty" + ], + "note": "Fixed triage pass (N: 259)" + }, + { + "id": "19b662b12efcfbad", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 260)" + }, + { + "id": "19b6627030176f0a", + "labels": [ + "! Prob Basura", + "Lider BCI", + "Español", + "Loyalty", + "Bancos" + ], + "note": "Fixed triage pass (N: 261)" + }, + { + "id": "19b662375a09cd18", + "labels": [ + "! Promoción", + "Inglés", + "Otro", + "Loyalty" + ], + "note": "Fixed triage pass (N: 262)" + }, + { + "id": "19b662148d4fcb78", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 263)" + }, + { + "id": "19b661ed65ccb2a8", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 264)" + }, + { + "id": "19b661dad0bce49b", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 265)" + }, + { + "id": "19b6608d860fc0b8", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 266)" + }, + { + "id": "19b65fb262155500", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 267)" + }, + { + "id": "19b65f4c38383311", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 268)" + }, + { + "id": "19b65efc770262bc", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 269)" + }, + { + "id": "19b65ebc69aa3e35", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 270)" + }, + { + "id": "19b65e99a1c0ec12", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 271)" + }, + { + "id": "19b65e89e952dee0", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 272)" + }, + { + "id": "19b65b5905e78e6c", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 273)" + }, + { + "id": "19b659c482b95a44", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 274)" + }, + { + "id": "19b658af24861488", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 275)" + }, + { + "id": "19b658a7318adab1", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 276)" + }, + { + "id": "19b65853105e6e92", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 277)" + }, + { + "id": "19b657eacf19878a", + "labels": [ + "! Promoción", + "Inglés", + "Otro", + "Loyalty" + ], + "note": "Fixed triage pass (N: 278)" + }, + { + "id": "19b657e2b9af3744", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 279)" + }, + { + "id": "19b657a8b26a4db2", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 280)" + }, + { + "id": "19b6573fa080f5a9", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 281)" + }, + { + "id": "19b65718038c2818", + "labels": [ + "! Promoción", + "Loyalty", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 282)" + }, + { + "id": "19b655f06cd3d38f", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 283)" + }, + { + "id": "19b654b5bdee38fa", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 284)" + }, + { + "id": "19b654ae2c959c94", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Falabella" + ], + "note": "Fixed triage pass (N: 285)" + }, + { + "id": "19b65461667abb6d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 286)" + }, + { + "id": "19b6543e20ed30ee", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 287)" + }, + { + "id": "19b650ef71d1f39c", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 288)" + }, + { + "id": "19b650e6c77d2a98", + "labels": [ + "! Prob Basura", + "Español", + "Lider BCI", + "Bancos" + ], + "note": "Fixed triage pass (N: 289)" + }, + { + "id": "19b650cd8c5f2d5b", + "labels": [ + "! Prob Basura", + "Español", + "Tenpo", + "Bancos" + ], + "note": "Fixed triage pass (N: 290)" + }, + { + "id": "19b650c462f86379", + "labels": [ + "! Promoción", + "Revolut", + "Inglés", + "Bancos" + ], + "note": "Fixed triage pass (N: 291)" + }, + { + "id": "19b64fed54756d69", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 292)" + }, + { + "id": "19b64efa6ddb6d71", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 293)" + }, + { + "id": "19b64c80f449f057", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 294)" + }, + { + "id": "19b64b03138ac863", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 295)" + }, + { + "id": "19b6470fa7b53613", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 296)" + }, + { + "id": "19b6466cd63041a8", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 297)" + }, + { + "id": "19b6459728e90c50", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 298)" + }, + { + "id": "19b644d783f6a4e5", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 299)" + }, + { + "id": "19b64429b7afabb7", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 300)" + }, + { + "id": "19b6440767581971", + "labels": [ + "Otro", + "! Prob Basura", + "Portugués" + ], + "note": "Fixed triage pass (N: 301)" + }, + { + "id": "19b642097e6232e9", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 302)" + }, + { + "id": "19b640066b0c0d99", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 303)" + }, + { + "id": "19b63ffc99a7b97a", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 304)" + }, + { + "id": "19b63fe3193f57b0", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 305)" + }, + { + "id": "19b63ec3fc04c303", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 306)" + }, + { + "id": "19b63e4b2aee5530", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 307)" + }, + { + "id": "19b63dac28cdb4dd", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 308)" + }, + { + "id": "19b63da47288edfe", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 309)" + }, + { + "id": "19b63d0cc68d5425", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 310)" + }, + { + "id": "19b63ce9a2e04cef", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 311)" + }, + { + "id": "19de22e8637bfecf", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 312)" + }, + { + "id": "19de229aabd09b3a", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 313)" + }, + { + "id": "19de225b0b2ac3c7", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 314)" + }, + { + "id": "19de220e3bba7dec", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 315)" + }, + { + "id": "19de20f1f48894bc", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 316)" + }, + { + "id": "19de200f0bae64ce", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 317)" + }, + { + "id": "19de1e3a78fccae5", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 318)" + }, + { + "id": "19de1d53ffabe193", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 319)" + }, + { + "id": "19de1d176881cb6b", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 320)" + }, + { + "id": "19de1c41dca20d18", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 321)" + }, + { + "id": "19de17c30c679bbf", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 322)" + }, + { + "id": "19de13fd88826be5", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 323)" + }, + { + "id": "19de1263c3b38735", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 324)" + }, + { + "id": "19de0d81e32ef480", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 325)" + }, + { + "id": "19de0bb408e0d7a3", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 326)" + }, + { + "id": "19de0b7e28021be8", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 327)" + }, + { + "id": "19de08a5a624654b", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 328)" + }, + { + "id": "19de089059f38e44", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 329)" + }, + { + "id": "19de06e5ed4ec1f2", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 330)" + }, + { + "id": "19de05cc6ae8f1ec", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 331)" + }, + { + "id": "19de055aaec69e49", + "labels": [ + "! Prob Basura", + "Español", + "Tenpo", + "Bancos" + ], + "note": "Fixed triage pass (N: 332)" + }, + { + "id": "19de04ca9850953b", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 333)" + }, + { + "id": "19de04c7462eff88", + "labels": [ + "! Promoción", + "Español", + "Lider BCI", + "Bancos" + ], + "note": "Fixed triage pass (N: 334)" + }, + { + "id": "19de040bb2573566", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 335)" + }, + { + "id": "19de03f060d96beb", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 336)" + }, + { + "id": "19de0364105bd1ac", + "labels": [ + "Cobranza", + "Español", + "Falabella", + "Bancos", + "! Importante" + ], + "note": "Fixed triage pass (N: 337)" + }, + { + "id": "19de0174cb056ea2", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 338)" + }, + { + "id": "19de016729b8f3bb", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 339)" + }, + { + "id": "19de0096579f1ed2", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "MACH" + ], + "note": "Fixed triage pass (N: 340)" + }, + { + "id": "19de00032a5ad272", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 341)" + }, + { + "id": "19ddffdd9c94c598", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 342)" + }, + { + "id": "19ddffd8e4368930", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 343)" + }, + { + "id": "19ddffcdb92be7b3", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 344)" + }, + { + "id": "19ddffbd72e39cb0", + "labels": [ + "! Promoción", + "Español", + "Servicios", + "Servidores" + ], + "note": "Fixed triage pass (N: 345)" + }, + { + "id": "19ddff53803f278d", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 346)" + }, + { + "id": "19ddfec082754833", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 347)" + }, + { + "id": "19ddfe1af7b6da51", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 348)" + }, + { + "id": "19ddfd089406aea9", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 349)" + }, + { + "id": "19ddfc54272df820", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 350)" + }, + { + "id": "19ddfa9bed2c1727", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 351)" + }, + { + "id": "19ddfa1cbf40c9ed", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 352)" + }, + { + "id": "19ddf93bbe61bfe0", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 353)" + }, + { + "id": "19ddf8c37eaf92f4", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 354)" + }, + { + "id": "19ddf66aeb138011", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 355)" + }, + { + "id": "19ddf6266f0431b5", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 356)" + }, + { + "id": "19ddf5c847e26906", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 357)" + }, + { + "id": "19ddf5c18c385a83", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 358)" + }, + { + "id": "19ddf56f6eba037d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 359)" + }, + { + "id": "19ddf56b0a639011", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 360)" + }, + { + "id": "19ddf2cacf16e644", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 361)" + }, + { + "id": "19ddf2501f863e37", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 362)" + }, + { + "id": "19ddf23eb11b26d4", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 363)" + }, + { + "id": "19ddf22cb83cad30", + "labels": [ + "! Prob Basura", + "Español", + "Lider BCI", + "Bancos" + ], + "note": "Fixed triage pass (N: 364)" + }, + { + "id": "19ddf22996ad5519", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 365)" + }, + { + "id": "19ddf2177fd4ca75", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 366)" + }, + { + "id": "19ddf1fbccd93b09", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 367)" + }, + { + "id": "19ddf17606b3a489", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 368)" + }, + { + "id": "19ddf087a859434f", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 369)" + }, + { + "id": "19ddf0411d67cd19", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 370)" + }, + { + "id": "19ddef99007536f7", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 371)" + }, + { + "id": "19ddef8e3bdf4e96", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 372)" + }, + { + "id": "19ddef104c15f5b4", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 373)" + }, + { + "id": "19ddeefaa3f3043d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 374)" + }, + { + "id": "19ddeeb3d15371ea", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 375)" + }, + { + "id": "19ddee857aa5bc2f", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 376)" + }, + { + "id": "19ddee6677920be7", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 377)" + }, + { + "id": "19ddede9c71369f1", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 378)" + }, + { + "id": "19ddeda9dd609c03", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 379)" + }, + { + "id": "19dded9270a27420", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 380)" + }, + { + "id": "19ddeca720f140f4", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 381)" + }, + { + "id": "19ddebe44639b4e9", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 382)" + }, + { + "id": "19ddebd1c895473a", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 383)" + }, + { + "id": "19ddeb985f523ec8", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 384)" + }, + { + "id": "19ddeb6b37e7945f", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 385)" + }, + { + "id": "19ddeb6aff6b6052", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 386)" + }, + { + "id": "19ddeb5ba2a4dc79", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 387)" + }, + { + "id": "19ddeb1c85992f79", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 388)" + }, + { + "id": "19ddea5f78c43eda", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 389)" + }, + { + "id": "19ddea2a79d8ebb0", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 390)" + }, + { + "id": "19ddea100138659f", + "labels": [ + "! Prob Basura", + "Español", + "Salud", + "Seguro Salud" + ], + "note": "Fixed triage pass (N: 391)" + }, + { + "id": "19dde98bcf1869af", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 392)" + }, + { + "id": "19dde95447d73c95", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 393)" + }, + { + "id": "19dde8ee4b8dcbad", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 394)" + }, + { + "id": "19dde8541edb9807", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 395)" + }, + { + "id": "19dde83b1ad8e301", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 396)" + }, + { + "id": "19dde7d561f95480", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 397)" + }, + { + "id": "19dde7c32d948174", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 398)" + }, + { + "id": "19dde7a4b5ef0f39", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 399)" + }, + { + "id": "19dde76a4b848e0c", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 400)" + }, + { + "id": "19dde6f26b7709da", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 401)" + }, + { + "id": "19dde4ab9b2c517c", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 402)" + }, + { + "id": "19dde47a0839cdbb", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 403)" + }, + { + "id": "19dde0e93a6c6eba", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 404)" + }, + { + "id": "19dde0e77fab78dc", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 405)" + }, + { + "id": "19dde0d82671897a", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 406)" + }, + { + "id": "19dde05ce98149b5", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 407)" + }, + { + "id": "19dddff90896aec5", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 408)" + }, + { + "id": "19dddf1b904d9755", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 409)" + }, + { + "id": "19ddde8f7b93c1ad", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 410)" + }, + { + "id": "19dddc863dc878bf", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 411)" + }, + { + "id": "19ddd6bccc7818c0", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 412)" + }, + { + "id": "19ddd677d53768e5", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 413)" + }, + { + "id": "19ddd58ae5a3d24e", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 414)" + }, + { + "id": "19ddd52e917483a6", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 415)" + }, + { + "id": "19ddd42cea3999db", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 416)" + }, + { + "id": "19ddd3180b15bf12", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 417)" + }, + { + "id": "19ddd2270cab95cc", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 418)" + }, + { + "id": "19ddd226eb66d287", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 419)" + }, + { + "id": "19ddd163e85abc2c", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 420)" + }, + { + "id": "19ddd05728e9b001", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 421)" + }, + { + "id": "19ddcfad6ae164c7", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 422)" + }, + { + "id": "19ddcfa09f098d76", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 423)" + }, + { + "id": "19ddcdec5577ca8a", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 424)" + }, + { + "id": "19ddccf7c263b31d", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 425)" + }, + { + "id": "19ddc1d9072eccab", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 426)" + }, + { + "id": "19ddbfe97724e8dd", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 427)" + }, + { + "id": "19ddbed16730b97c", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 428)" + }, + { + "id": "19ddbcdbd9e27556", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 429)" + }, + { + "id": "19ddbbe01c6066ae", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 430)" + }, + { + "id": "19ddbb39b0ec1659", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 431)" + }, + { + "id": "19ddbaf7366f63b4", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 432)" + }, + { + "id": "19ddba20f2f369cf", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 433)" + }, + { + "id": "19ddb7f0f4745393", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 434)" + }, + { + "id": "19ddb7cc246314b6", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 435)" + }, + { + "id": "19ddb6f4b2f9dbd5", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 436)" + }, + { + "id": "19ddb6c89eb6b37e", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 437)" + }, + { + "id": "19ddb5cf83a3aeea", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 438)" + }, + { + "id": "19ddb52d0ac2780e", + "labels": [ + "! Promoción", + "Español", + "Lider BCI", + "Bancos" + ], + "note": "Fixed triage pass (N: 439)" + }, + { + "id": "19ddb4382b0e1fc9", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 440)" + }, + { + "id": "19ddb18a8a785542", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 441)" + }, + { + "id": "19ddb1869e57ee45", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 442)" + }, + { + "id": "19ddaf7750f3ea98", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 443)" + }, + { + "id": "19ddaef6f0daff77", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 444)" + }, + { + "id": "19ddae6ef01e391e", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 445)" + }, + { + "id": "19ddae3313451397", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 446)" + }, + { + "id": "19ddad7a11a55929", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 447)" + }, + { + "id": "19ddad66e605b28d", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 448)" + }, + { + "id": "19ddad4b608c00c4", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 449)" + }, + { + "id": "19ddaba11386aa6d", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 450)" + }, + { + "id": "19ddaa05381d4644", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 451)" + }, + { + "id": "19dda8eee2f54f87", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 452)" + }, + { + "id": "19dda69a9e140566", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 453)" + }, + { + "id": "19dda6745b3d1c8c", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 454)" + }, + { + "id": "19dda557b92abbeb", + "labels": [ + "! Promoción", + "Español", + "Lider BCI", + "Bancos" + ], + "note": "Fixed triage pass (N: 455)" + }, + { + "id": "19dda52918e1c0e5", + "labels": [ + "! Prob Basura", + "Español", + "Bancos", + "MACH" + ], + "note": "Fixed triage pass (N: 456)" + }, + { + "id": "19dda49904aba7af", + "labels": [ + "! Promoción", + "Español", + "Lider BCI", + "Bancos" + ], + "note": "Fixed triage pass (N: 457)" + }, + { + "id": "19dda40507079b82", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 458)" + }, + { + "id": "19dda3a2fa1e32f0", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 459)" + }, + { + "id": "19dda38ca55b0349", + "labels": [ + "! Prob Basura", + "Español", + "Tenpo", + "Bancos" + ], + "note": "Fixed triage pass (N: 460)" + }, + { + "id": "19dda07aa34415d4", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 461)" + }, + { + "id": "19dda078947afbb1", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 462)" + }, + { + "id": "19dd9ff76fda80ad", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 463)" + }, + { + "id": "19dd9fbc171c37e6", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 464)" + }, + { + "id": "19dd9fb78ed5d838", + "labels": [ + "! Promoción", + "Español", + "Otro", + "Loyalty" + ], + "note": "Fixed triage pass (N: 465)" + }, + { + "id": "19dd9ef6a02c4ed6", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 466)" + }, + { + "id": "19dd9ece83675a5c", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 467)" + }, + { + "id": "19dd9e4f0edac843", + "labels": [ + "Otro", + "! Prob Basura", + "Español" + ], + "note": "Fixed triage pass (N: 468)" + }, + { + "id": "19dd9cccb7521f75", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 469)" + }, + { + "id": "19dd9cc3d1863344", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 470)" + }, + { + "id": "19dd9c85edf9427c", + "labels": [ + "Otro", + "! Prob Basura", + "Inglés" + ], + "note": "Fixed triage pass (N: 471)" + }, + { + "id": "19dd9c6d7b3b365f", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 472)" + }, + { + "id": "19dd9bb31b7a7b90", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 473)" + }, + { + "id": "19dd9ba149d45b5b", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 474)" + }, + { + "id": "19dd9ab624c4a30d", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 475)" + }, + { + "id": "19dd9927d63aa8e3", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 476)" + }, + { + "id": "19dd991dba06a5c7", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 477)" + }, + { + "id": "19dd98b9fdf22cb7", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 478)" + }, + { + "id": "19dd985af00cfe71", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 479)" + }, + { + "id": "19dd97a095802229", + "labels": [ + "! Prob Basura", + "Español", + "Salud", + "Seguro Salud" + ], + "note": "Fixed triage pass (N: 480)" + }, + { + "id": "19dd975d27a36eed", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 481)" + }, + { + "id": "19dd975a5af0c032", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 482)" + }, + { + "id": "19dd9700b5de42d0", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 483)" + }, + { + "id": "19dd96ed8b7b86b8", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 484)" + }, + { + "id": "19dd95f8d239d7b1", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 485)" + }, + { + "id": "19dd95cdbee0ba0c", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 486)" + }, + { + "id": "19dd95795dedbeb1", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 487)" + }, + { + "id": "19dd95662e799890", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 488)" + }, + { + "id": "19dd94a27cf9b49f", + "labels": [ + "! Promoción", + "Español", + "Otro" + ], + "note": "Fixed triage pass (N: 489)" + }, + { + "id": "19dd9279de35008b", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 490)" + }, + { + "id": "19dd91d3fc385f65", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 491)" + }, + { + "id": "19dd91176ecc088f", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 492)" + }, + { + "id": "19dd909dab48b526", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 493)" + }, + { + "id": "19dd90278316d4fb", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 494)" + }, + { + "id": "19dd9010a0e57795", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 495)" + }, + { + "id": "19dd8ed06066ce50", + "labels": [ + "! Promoción", + "Inglés", + "Otro" + ], + "note": "Fixed triage pass (N: 496)" + }, + { + "id": "19dd8e96be99a571", + "labels": [ + "! Promoción", + "Otro", + "Portugués" + ], + "note": "Fixed triage pass (N: 497)" + }, + { + "id": "19dd8cadb8c4cb98", + "labels": [ + "Inglés", + "! Prob Basura", + "Bancos", + "Revolut" + ], + "note": "Fixed triage pass (N: 498)" + }, + { + "id": "19dd8c922a3df539", + "labels": [ + "Otro", + "! Prob Basura", + "Alemán" + ], + "note": "Fixed triage pass (N: 499)" + }, + { + "id": "19dd8af19ed207f6", + "labels": [ + "! Promoción", + "Otro", + "Alemán" + ], + "note": "Fixed triage pass (N: 500)" + } +] \ No newline at end of file diff --git a/data/example-ledger.json b/data/example-ledger.json new file mode 100644 index 0000000..58dcc62 --- /dev/null +++ b/data/example-ledger.json @@ -0,0 +1,128 @@ +{ + "entities": [ + { + "id": "darwin", + "name": "Darwin Bruna", + "kind": "person", + "rut": "17.194.206-3" + }, + { + "id": "muralla", + "name": "Muralla SpA", + "kind": "company", + "rut": "78.188.363-8" + }, + { + "id": "black-spa", + "name": "BLACK SPA", + "kind": "vendor" + } + ], + "accounts": [ + { + "id": "darwin-bchile-cc-5402", + "ownerEntityId": "darwin", + "institution": "Banco de Chile / Edwards", + "instrument": "checking_account", + "currency": "CLP", + "label": "Cuenta corriente Darwin terminada 5402" + }, + { + "id": "darwin-lider-visa-5018", + "ownerEntityId": "darwin", + "institution": "Tarjeta Lider", + "instrument": "credit_card", + "currency": "CLP", + "label": "Tarjeta credito Darwin terminada 5018" + } + ], + "movements": [ + { + "id": "darwin-income-2025-12-05", + "date": "2025-12-05", + "accountId": "darwin-bchile-cc-5402", + "direction": "in", + "amount": { "currency": "CLP", "value": 500000 }, + "description": "Ingreso puro Darwin sin trazabilidad anterior", + "counterparty": "Ingreso externo", + "economicType": "pure_income" + }, + { + "id": "darwin-bank-pay-visa-2026-01-15", + "date": "2026-01-15", + "accountId": "darwin-bchile-cc-5402", + "direction": "out", + "amount": { "currency": "CLP", "value": 300000 }, + "description": "PAGO TARJETA LIDER VISA 5018", + "counterparty": "Tarjeta Lider", + "economicType": "card_payment", + "cardAccountId": "darwin-lider-visa-5018" + }, + { + "id": "darwin-visa-black-spa-2025-12-30", + "date": "2025-12-30", + "accountId": "darwin-lider-visa-5018", + "direction": "out", + "amount": { "currency": "CLP", "value": 126616 }, + "description": "CAFE CULTURA BLACK SPA", + "counterparty": "BLACK SPA", + "economicType": "card_charge", + "beneficiaryEntityId": "muralla" + }, + { + "id": "darwin-visa-cocina-con-alma-2025-12-30", + "date": "2025-12-30", + "accountId": "darwin-lider-visa-5018", + "direction": "out", + "amount": { "currency": "CLP", "value": 78255 }, + "description": "MERPAGO*COCINACONALMA", + "counterparty": "Cocina con Alma", + "economicType": "card_charge", + "beneficiaryEntityId": "por_confirmar" + } + ], + "documents": [ + { + "id": "black-spa-10804", + "kind": "dte_invoice", + "issuerName": "BLACK SPA", + "receiverEntityId": "muralla", + "folio": "10804", + "documentDate": "2025-12-30", + "amount": { "currency": "CLP", "value": 126616 }, + "status": "matched" + } + ], + "events": [ + { + "id": "black-spa-10804", + "kind": "real_expense", + "entityId": "muralla", + "date": "2025-12-30", + "amount": { "currency": "CLP", "value": 126616 }, + "description": "Gasto Muralla BLACK SPA folio 10804" + } + ], + "links": [ + { + "from": "doc:black-spa-10804", + "to": "event:black-spa-10804", + "type": "documents_event", + "amount": { "currency": "CLP", "value": 126616 }, + "method": "exact", + "confidence": "exact", + "state": "approved", + "note": "Factura RCV Muralla 2026-01, fecha documento 2025-12-30." + }, + { + "from": "mov:darwin-visa-black-spa-2025-12-30", + "to": "event:black-spa-10804", + "type": "finances_event", + "amount": { "currency": "CLP", "value": 126616 }, + "method": "manual", + "confidence": "exact", + "state": "approved", + "note": "Cargo CAFE CULTURA BLACK SPA pagado con tarjeta Darwin 5018." + } + ] +} diff --git a/debug-labels.js b/debug-labels.js new file mode 100644 index 0000000..5f0ae49 --- /dev/null +++ b/debug-labels.js @@ -0,0 +1,29 @@ +import { google } from 'googleapis'; +import { loadAuthorizedOAuthClient } from './src/gmailApi.js'; +import { getMailAccount } from './src/mailConfig.js'; + +async function check() { + const account = await getMailAccount('vjoati-gmail'); + const auth = await loadAuthorizedOAuthClient({ account }); + const gmail = google.gmail({ version: 'v1', auth }); + + const msgId = '19de22e8637bfecf'; // ZEIT SPRACHEN from May 1 + try { + const res = await gmail.users.messages.get({ userId: 'me', id: msgId }); + console.log('--- Message Metadata ---'); + console.log('ID:', res.data.id); + console.log('Snippet:', res.data.snippet); + console.log('Label IDs:', JSON.stringify(res.data.labelIds)); + + const labelsRes = await gmail.users.labels.list({ userId: 'me' }); + const labels = labelsRes.data.labels; + console.log('\n--- Label Mapping for this Message ---'); + res.data.labelIds.forEach(id => { + const match = labels.find(l => l.id === id); + console.log(id + ' -> ' + (match ? match.name : 'Unknown')); + }); + } catch (e) { + console.error('Error:', e.message); + } +} +check(); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f071e4b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,109 @@ +# Arquitectura + +## Alcance + +`kua-money-trace` mantiene una fuente unica de verdad para: + +- movimientos financieros +- documentos tributarios y no tributarios +- archivos/correos originales +- enlaces entre movimientos +- arboles de origen de fondos +- decisiones humanas de revision + +## Principios + +1. Todo archivo original se conserva intacto. +2. Todo analisis de IA es una propuesta revisable. +3. Todo movimiento tiene tipo economico. +4. Un pago de tarjeta no es gasto: liquida deuda de tarjeta. +5. Un fondeo de billetera o cuenta extranjera no es ingreso operacional. +6. La plata en una cuenta es fungible; si no hay enlace exacto, se usa FIFO por cuenta y moneda. +7. Cada enlace tiene metodo, confianza y estado. + +## Pipeline + +```text +IMAP / archivos manuales / APIs banco + -> archivo raw en Storagebox + -> extraccion de adjuntos + -> parser deterministico + -> clasificador IA + -> ledger normalizado + -> grafo de dinero + -> revision humana + -> export contable +``` + +## Ingesta inicial Gmail + +Cuenta inicial: + +```text +vjoati@gmail.com +``` + +Config local: + +```text +config/mail-accounts.json +``` + +Credencial esperada: + +```text +VJOATI_GMAIL_APP_PASSWORD +``` + +Por seguridad, el downloader solo guarda: + +- correo original `.eml` +- adjuntos originales +- hash SHA-256 +- manifiesto NDJSON +- preview de texto + +La IA debe leer desde este archivo y crear propuestas separadas. No debe modificar los originales. + +## Alternativas cuando Gmail no permite app password + +1. **Apple Mail local (`.emlx`)**: si la cuenta ya esta sincronizada en Mail.app, importamos los mensajes locales sin pedir credenciales nuevas. +2. **Google Takeout (`.mbox`)**: sirve para carga historica masiva sin OAuth ni IMAP. +3. **Gmail API OAuth**: camino correcto para sync continuo si no hay app password. Usaria `gmail.readonly` y `users.messages.get(format=raw)`. + +## Servicios existentes + +- `kua-mail`: reutilizable como referencia para IMAP, sync, folders, threads, `mailparser`. +- `kua-notify`: no sirve para inbound mail, pero si como referencia de servicio Fastify con Vault, auditoria, healthcheck y auth. +- Storagebox: reutilizar patron SFTP via `ssh2-sftp-client`. + +## Storagebox sugerido + +```text +/accounting-mail/ + raw-eml/{mailbox}/{yyyy}/{mm}/{message_hash}.eml + attachments/{yyyy}/{mm}/{sha256}-{original_filename} + normalized/{source}/{entity}/{account}/{period}/{sha256}.json + manifests/documents.ndjson + manifests/extractions.ndjson +``` + +## Nodos del grafo + +- `movement:*`: cartola, tarjeta, Mercado Pago, Revolut, etc. +- `document:*`: factura, boleta, invoice, recibo, transferencia. +- `event:*`: gasto real, ingreso real, deuda, reembolso, ajuste. +- `file:*`: archivo original en Storagebox. + +## Enlaces del grafo + +Direccion: `from` financia, explica o liquida `to`. + +Ejemplo: + +```text +movement:darwin-income-001 -> movement:darwin-bank-pay-visa-001 +movement:darwin-bank-pay-visa-001 -> movement:darwin-visa-black-spa-001 +movement:darwin-visa-black-spa-001 -> event:black-spa-10804 +document:black-spa-10804 -> event:black-spa-10804 +``` diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..2231b03 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,122 @@ +# Modelo de Datos MVP + +## Entity + +Persona o empresa. + +```json +{ + "id": "darwin", + "name": "Darwin Bruna", + "kind": "person", + "rut": "17.194.206-3" +} +``` + +## Account + +Cuenta, tarjeta o billetera. + +```json +{ + "id": "darwin-bchile-cc-5402", + "ownerEntityId": "darwin", + "institution": "Banco de Chile", + "instrument": "checking_account", + "currency": "CLP", + "label": "Cuenta corriente 5402" +} +``` + +Instrumentos iniciales: + +- `checking_account` +- `current_account` +- `savings_account` +- `credit_card` +- `debit_card` +- `prepaid_card` +- `wallet` +- `foreign_account` +- `cash` + +## Movement + +Movimiento financiero observado. + +```json +{ + "id": "mov:darwin-visa-black-spa", + "date": "2025-12-30", + "accountId": "darwin-lider-visa-5018", + "direction": "out", + "amount": { "currency": "CLP", "value": 126616 }, + "description": "CAFE CULTURA BLACK SPA", + "counterparty": "BLACK SPA", + "economicType": "card_charge", + "beneficiaryEntityId": "muralla" +} +``` + +## Document + +Documento de respaldo. + +```json +{ + "id": "doc:black-spa-10804", + "kind": "dte_invoice", + "issuerName": "BLACK SPA", + "issuerRut": "76.xxx.xxx-x", + "receiverEntityId": "muralla", + "folio": "10804", + "documentDate": "2025-12-30", + "amount": { "currency": "CLP", "value": 126616 } +} +``` + +## Economic Event + +Evento economico real: gasto, ingreso, deuda, reembolso. + +```json +{ + "id": "event:black-spa-10804", + "kind": "real_expense", + "entityId": "muralla", + "amount": { "currency": "CLP", "value": 126616 }, + "description": "Gasto Muralla BLACK SPA folio 10804" +} +``` + +## Link + +Enlace entre nodos. + +```json +{ + "from": "mov:darwin-bank-income-001", + "to": "mov:darwin-bank-pay-visa-001", + "type": "funds", + "amount": { "currency": "CLP", "value": 126616 }, + "method": "fifo", + "confidence": "rule", + "state": "proposed" +} +``` + +Estados: + +- `proposed` +- `approved` +- `rejected` +- `needs_review` + +Confianzas: + +- `exact` +- `strong` +- `probable` +- `rule` +- `manual` +- `unknown` diff --git a/kua.json b/kua.json new file mode 100644 index 0000000..6ebcd27 --- /dev/null +++ b/kua.json @@ -0,0 +1,121 @@ +{ + "name": "kua-money-trace", + "version": "0.1.0", + "description": "Money trace and mail/document ingestion service for accounting-grade fund origin trees.", + "type": "service", + "framework": "node", + "team": { + "owner": "kavi", + "status": "active" + }, + "stack": { + "backend": { + "runtime": "node", + "framework": "node", + "version": "20" + }, + "database": { + "type": "local-json", + "version": "mvp" + }, + "storage": { + "type": "local-files", + "planned": "storagebox" + } + }, + "services": { + "api": { + "port": 3910, + "healthcheck": "/health", + "command": "npm run serve" + }, + "gmail_oauth": { + "port": 3912, + "redirect_uri": "http://127.0.0.1:3912/oauth2callback", + "command": "npm run mail:gmail-oauth" + } + }, + "environments": { + "development": { + "server": "gal", + "path": "/home/kavi/kua-money-trace", + "port": 3910, + "secrets": { + "project": "kua-money-trace", + "env": "dev", + "include": ["shared"] + } + }, + "production": { + "server": "bruno", + "path": "/home/kavi/kua-money-trace", + "port": 3910, + "secrets": { + "project": "kua-money-trace", + "env": "prod", + "include": ["shared"] + } + } + }, + "secrets": { + "project": "kua-money-trace", + "include": ["shared"], + "required": [ + "GOOGLE_OAUTH_CLIENT_ID", + "GOOGLE_OAUTH_CLIENT_SECRET" + ] + }, + "migration": { + "databases": [], + "volumes": [ + { + "name": "mail-archive", + "path": "data/mail-archive", + "backup_required": true, + "note": "Raw emails, attachments, and manifests. Move to Storagebox before production ingestion." + }, + { + "name": "mail-oauth", + "path": "data/mail-oauth", + "backup_required": true, + "secret": true, + "note": "OAuth refresh tokens. Excluded from source transfer and should not be committed." + } + ], + "env_files": [], + "required_secrets": [ + "GOOGLE_OAUTH_CLIENT_ID", + "GOOGLE_OAUTH_CLIENT_SECRET" + ], + "post_migration": [ + "npm install", + "npm test" + ] + }, + "infrastructure": { + "database": "local-json", + "cache": "none", + "queue": "none", + "storage": "local-files", + "secrets": "kuavault" + }, + "dependencies": { + "runtime": { + "node": ">=20.0.0" + }, + "system": [ + "sqlite3" + ] + }, + "health": { + "endpoint": "/health", + "timeout": 15, + "expected_status": 200 + }, + "deploy": { + "production": { + "mode": "direct", + "server": "gal" + } + } +} diff --git a/list-labels.js b/list-labels.js new file mode 100644 index 0000000..e06c010 --- /dev/null +++ b/list-labels.js @@ -0,0 +1,13 @@ +import { google } from 'googleapis'; +import { loadAuthorizedOAuthClient } from './src/gmailApi.js'; +import { getMailAccount } from './src/mailConfig.js'; + +async function run() { + const account = await getMailAccount('vjoati-gmail'); + const auth = await loadAuthorizedOAuthClient({ account }); + const gmail = google.gmail({ version: 'v1', auth }); + + const res = await gmail.users.labels.list({ userId: 'me' }); + console.log(JSON.stringify(res.data.labels, null, 2)); +} +run(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6bf3b5d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1126 @@ +{ + "name": "kua-money-trace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kua-money-trace", + "version": "0.1.0", + "dependencies": { + "googleapis": "^171.4.0", + "imapflow": "^1.3.2", + "mailparser": "^3.9.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.9", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz", + "integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/imapflow": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.2.tgz", + "integrity": "sha512-lIhVpjAf+o/1SELeR34vqeoEjAyBOMd6xq2Vdx2E4XIRwDi5sfqfBqqr5YkTwp7G/Q3f5ACpU7/zuGklJeCj9Q==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.9", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1", + "nodemailer": "8.0.5", + "pino": "10.3.1", + "socks": "2.8.7" + } + }, + "node_modules/ip-address": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", + "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz", + "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/mailparser": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz", + "integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.2", + "libmime": "5.3.8", + "linkify-it": "5.0.0", + "nodemailer": "8.0.5", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mailparser/node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, + "node_modules/mailparser/node_modules/@zone-eu/mailsplit/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mailparser/node_modules/@zone-eu/mailsplit/node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..41e29d4 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "kua-money-trace", + "version": "0.1.0", + "description": "Money trace service for accounting-grade fund origin trees.", + "type": "module", + "private": true, + "scripts": { + "test": "node --test", + "demo": "node src/cli.js trace data/example-ledger.json event:black-spa-10804", + "summarize": "node src/cli.js summarize data/example-ledger.json", + "mail:dry-run": "node src/mailCli.js dry-run --account vjoati-gmail", + "mail:download": "node src/mailCli.js download --account vjoati-gmail", + "mail:gmail-oauth": "node src/mailCli.js gmail-oauth --account vjoati-gmail", + "mail:gmail-download": "node src/mailCli.js gmail-download --account vjoati-gmail", + "mail:gmail-trash": "node src/mailCli.js gmail-trash --account vjoati-gmail", + "mail:fixture": "node src/mailCli.js parse-fixture test/fixtures/sample-bank-email.eml --account vjoati-gmail", + "mail:list-apple": "node src/mailCli.js list-apple-mail", + "mail:import-apple-index": "node src/mailCli.js import-apple-mail-index --account vjoati-gmail", + "mail:import-kdoi": "node src/mailCli.js import-apple-mail-index --account kdoi-email", + "serve": "node src/server.js --ledger data/example-ledger.json --port 3910" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "googleapis": "^171.4.0", + "imapflow": "^1.3.2", + "mailparser": "^3.9.8" + } +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..b605f9a --- /dev/null +++ b/schema.sql @@ -0,0 +1,113 @@ +-- kua-money-trace MVP schema draft +-- Designed for Postgres. The current MVP uses JSON files, but this schema +-- defines the service boundary before adding persistence. + +create table if not exists entities ( + id text primary key, + name text not null, + kind text not null check (kind in ('person', 'company', 'vendor', 'bank', 'platform', 'unknown')), + rut text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists financial_accounts ( + id text primary key, + owner_entity_id text references entities(id), + institution text not null, + instrument text not null, + currency text not null, + label text, + account_number_hint text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists source_files ( + id text primary key, + storage_key text not null unique, + sha256 text not null, + original_filename text, + mime_type text, + size_bytes bigint, + source_kind text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists mail_messages ( + id text primary key, + mailbox text not null, + message_id text, + from_addr jsonb, + to_addrs jsonb, + subject text, + date_sent timestamptz, + raw_file_id text references source_files(id), + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists documents ( + id text primary key, + kind text not null, + issuer_name text, + issuer_rut text, + receiver_entity_id text references entities(id), + folio text, + document_date date, + currency text not null, + amount numeric(18, 4) not null, + source_file_id text references source_files(id), + extraction_state text not null default 'proposed', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists financial_movements ( + id text primary key, + account_id text not null references financial_accounts(id), + occurred_at timestamptz not null, + direction text not null check (direction in ('in', 'out')), + currency text not null, + amount numeric(18, 4) not null, + description text, + counterparty text, + economic_type text not null default 'unknown', + beneficiary_entity_id text references entities(id), + source_file_id text references source_files(id), + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists economic_events ( + id text primary key, + kind text not null, + entity_id text references entities(id), + occurred_at timestamptz, + currency text not null, + amount numeric(18, 4) not null, + description text, + state text not null default 'proposed', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists money_links ( + id text primary key, + from_node text not null, + to_node text not null, + link_type text not null, + currency text not null, + amount numeric(18, 4) not null, + method text not null, + confidence text not null default 'unknown', + state text not null default 'proposed', + note text, + created_at timestamptz not null default now() +); + +create index if not exists idx_money_links_from on money_links(from_node); +create index if not exists idx_money_links_to on money_links(to_node); +create index if not exists idx_financial_movements_account_date on financial_movements(account_id, occurred_at); +create index if not exists idx_documents_receiver_date on documents(receiver_entity_id, document_date); diff --git a/src/classifier.js b/src/classifier.js new file mode 100644 index 0000000..2f58ff9 --- /dev/null +++ b/src/classifier.js @@ -0,0 +1,79 @@ +import { ECONOMIC_TYPES } from './domain.js'; + +const CARD_PAYMENT_PATTERNS = [ + /pago\s*(tc|tarjeta|visa|mastercard|cmr)/i, + /cargo por pago tc/i, + /pago.*tarjeta/i, +]; + +const WALLET_FUNDING_PATTERNS = [ + /ingreso de dinero/i, + /carga.*mercado pago/i, + /top[-\s]?up/i, + /revolut/i, +]; + +const NON_ACCOUNTING_PATTERNS = [ + /interes/i, + /impuesto linea de credito/i, + /comision admin/i, + /saldo inicial/i, + /saldo final/i, +]; + +export function classifyMovement(movement, account) { + if (movement.economicType) return movement.economicType; + const text = [movement.description, movement.counterparty].filter(Boolean).join(' '); + + if (NON_ACCOUNTING_PATTERNS.some((pattern) => pattern.test(text))) { + return ECONOMIC_TYPES.NON_ACCOUNTING; + } + + if (account?.instrument === 'credit_card' && movement.direction === 'out') { + return ECONOMIC_TYPES.CARD_CHARGE; + } + + if (movement.direction === 'out' && CARD_PAYMENT_PATTERNS.some((pattern) => pattern.test(text))) { + return ECONOMIC_TYPES.CARD_PAYMENT; + } + + if (movement.direction === 'in' && account?.instrument === 'wallet') { + return ECONOMIC_TYPES.WALLET_FUNDING; + } + + if (movement.direction === 'in' && account?.instrument === 'foreign_account' && WALLET_FUNDING_PATTERNS.some((pattern) => pattern.test(text))) { + return ECONOMIC_TYPES.FOREIGN_ACCOUNT_FUNDING; + } + + if (movement.direction === 'in') { + return account?.ownerKind === 'company' ? ECONOMIC_TYPES.OPERATING_INCOME : ECONOMIC_TYPES.PURE_INCOME; + } + + if (movement.direction === 'out') { + return ECONOMIC_TYPES.REAL_EXPENSE; + } + + return ECONOMIC_TYPES.UNKNOWN; +} + +export function classifyAllMovements(ledger) { + const entityById = new Map(ledger.entities.map((entity) => [entity.id, entity])); + const accountById = new Map(ledger.accounts.map((account) => { + const owner = entityById.get(account.ownerEntityId); + return [account.id, { ...account, ownerKind: owner?.kind || null }]; + })); + + return { + ...ledger, + movements: ledger.movements.map((movement) => { + const rawAccountId = movement.accountId?.includes(':') + ? movement.accountId.split(':').at(-1) + : movement.accountId; + const account = accountById.get(rawAccountId); + return { + ...movement, + economicType: classifyMovement(movement, account), + }; + }), + }; +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..4709633 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { buildMoneyGraph, destinationTree, originTree, summarizeLedger } from './moneyGraph.js'; +import { formatDestinationTree, formatOriginTree } from './formatTree.js'; +import { loadLedger, resolveAccountOwnerHints } from './ledgerStore.js'; + +const [command, ledgerPath, nodeId] = process.argv.slice(2); + +if (!command || !ledgerPath) { + usage(); + process.exit(1); +} + +const ledger = resolveAccountOwnerHints(await loadLedger(ledgerPath)); +const graph = buildMoneyGraph(ledger); + +if (command === 'summarize') { + console.log(JSON.stringify(summarizeLedger(ledger, graph), null, 2)); +} else if (command === 'trace') { + if (!nodeId) { + console.error('trace requires node id'); + process.exit(1); + } + console.log(formatOriginTree(originTree(graph, nodeId))); +} else if (command === 'destinations') { + if (!nodeId) { + console.error('destinations requires node id'); + process.exit(1); + } + console.log(formatDestinationTree(destinationTree(graph, nodeId))); +} else { + usage(); + process.exit(1); +} + +function usage() { + console.error(`Usage: + node src/cli.js summarize + node src/cli.js trace + node src/cli.js destinations `); +} diff --git a/src/domain.js b/src/domain.js new file mode 100644 index 0000000..9c48612 --- /dev/null +++ b/src/domain.js @@ -0,0 +1,133 @@ +export const ECONOMIC_TYPES = Object.freeze({ + PURE_INCOME: 'pure_income', + OPERATING_INCOME: 'operating_income', + REAL_EXPENSE: 'real_expense', + CARD_CHARGE: 'card_charge', + CARD_PAYMENT: 'card_payment', + WALLET_FUNDING: 'wallet_funding', + FOREIGN_ACCOUNT_FUNDING: 'foreign_account_funding', + INTERNAL_TRANSFER: 'internal_transfer', + REIMBURSEMENT: 'reimbursement', + PARTNER_LOAN: 'partner_loan', + PARTNER_WITHDRAWAL: 'partner_withdrawal', + REFUND: 'refund', + ADJUSTMENT: 'adjustment', + NON_ACCOUNTING: 'non_accounting', + UNKNOWN: 'unknown', +}); + +export const LINK_TYPES = Object.freeze({ + FUNDS: 'funds', + SETTLES_CARD_CHARGE: 'settles_card_charge', + FINANCES_EVENT: 'finances_event', + DOCUMENTS_EVENT: 'documents_event', + INTERNAL_TRANSFER: 'internal_transfer', + REIMBURSES: 'reimburses', + FX_CONVERSION: 'fx_conversion', + PLATFORM_PAYMENT: 'platform_payment', +}); + +export const LINK_METHODS = Object.freeze({ + EXACT: 'exact', + RULE_FIFO: 'rule_fifo', + MANUAL: 'manual', + AI_PROPOSED: 'ai_proposed', + IMPORTED: 'imported', +}); + +export function amount(currency, value) { + if (!currency || typeof currency !== 'string') { + throw new Error('amount.currency is required'); + } + if (!Number.isFinite(value)) { + throw new Error('amount.value must be a finite number'); + } + return { currency, value }; +} + +export function signedAmount(movement) { + const sign = movement.direction === 'out' ? -1 : 1; + return sign * movement.amount.value; +} + +export function nodeId(kind, id) { + if (id.includes(':')) return id; + return `${kind}:${id}`; +} + +export function compareDateThenId(a, b) { + const byDate = String(a.date || '').localeCompare(String(b.date || '')); + if (byDate !== 0) return byDate; + return String(a.id).localeCompare(String(b.id)); +} + +export function assertLedgerShape(ledger) { + if (!ledger || typeof ledger !== 'object') throw new Error('ledger must be an object'); + for (const key of ['entities', 'accounts', 'movements']) { + if (!Array.isArray(ledger[key])) throw new Error(`ledger.${key} must be an array`); + } + if (ledger.documents && !Array.isArray(ledger.documents)) { + throw new Error('ledger.documents must be an array'); + } + if (ledger.events && !Array.isArray(ledger.events)) { + throw new Error('ledger.events must be an array'); + } + if (ledger.links && !Array.isArray(ledger.links)) { + throw new Error('ledger.links must be an array'); + } +} + +export function normalizeLedger(raw) { + assertLedgerShape(raw); + return { + entities: raw.entities, + accounts: raw.accounts, + movements: raw.movements.map((movement) => ({ + ...movement, + id: nodeId('mov', movement.id), + })), + documents: (raw.documents || []).map((document) => ({ + ...document, + id: nodeId('doc', document.id), + })), + events: (raw.events || []).map((event) => ({ + ...event, + id: nodeId('event', event.id), + })), + links: (raw.links || []).map((link) => ({ + state: 'proposed', + confidence: 'unknown', + ...link, + from: normalizeExistingNodeId(link.from), + to: normalizeExistingNodeId(link.to), + })), + }; +} + +function normalizeExistingNodeId(id) { + if (typeof id !== 'string') throw new Error('link node ids must be strings'); + if (id.includes(':')) return id; + throw new Error(`link node id must include prefix: ${id}`); +} + +export function buildNodeIndex(ledger) { + const nodes = new Map(); + + for (const entity of ledger.entities) { + nodes.set(nodeId('entity', entity.id), { kind: 'entity', ...entity, id: nodeId('entity', entity.id) }); + } + for (const account of ledger.accounts) { + nodes.set(nodeId('account', account.id), { kind: 'account', ...account, id: nodeId('account', account.id) }); + } + for (const movement of ledger.movements) { + nodes.set(movement.id, { kind: 'movement', ...movement }); + } + for (const document of ledger.documents) { + nodes.set(document.id, { kind: 'document', ...document }); + } + for (const event of ledger.events) { + nodes.set(event.id, { kind: 'event', ...event }); + } + + return nodes; +} diff --git a/src/formatTree.js b/src/formatTree.js new file mode 100644 index 0000000..bd05268 --- /dev/null +++ b/src/formatTree.js @@ -0,0 +1,55 @@ +function labelNode(node) { + const amount = node.amount ? ` ${formatAmount(node.amount)}` : ''; + const date = node.date || node.documentDate ? ` ${node.date || node.documentDate}` : ''; + const text = node.description || node.subject || node.label || node.name || node.issuerName || node.id; + return `${node.id}${date}${amount} - ${text}`; +} + +function formatAmount(amount) { + const value = new Intl.NumberFormat('es-CL').format(amount.value); + return `${amount.currency} ${value}`; +} + +function labelLink(link) { + const amount = link.amount ? ` ${formatAmount(link.amount)}` : ''; + const method = link.method ? ` (${link.method})` : ''; + return `${link.type}${amount}${method}`; +} + +export function formatOriginTree(tree) { + const lines = []; + + function visit(branch, prefix, isLast) { + const connector = prefix ? (isLast ? '└─ ' : '├─ ') : ''; + lines.push(`${prefix}${connector}${labelNode(branch.node)}`); + const nextPrefix = prefix + (prefix ? (isLast ? ' ' : '│ ') : ''); + branch.incoming.forEach((incoming, index) => { + const lastIncoming = index === branch.incoming.length - 1; + const linkPrefix = nextPrefix + (lastIncoming ? '└─ ' : '├─ '); + lines.push(`${linkPrefix}${labelLink(incoming.link)}`); + visit(incoming.source, nextPrefix + (lastIncoming ? ' ' : '│ '), true); + }); + } + + visit(tree, '', true); + return lines.join('\n'); +} + +export function formatDestinationTree(tree) { + const lines = []; + + function visit(branch, prefix, isLast) { + const connector = prefix ? (isLast ? '└─ ' : '├─ ') : ''; + lines.push(`${prefix}${connector}${labelNode(branch.node)}`); + const nextPrefix = prefix + (prefix ? (isLast ? ' ' : '│ ') : ''); + branch.outgoing.forEach((outgoing, index) => { + const lastOutgoing = index === branch.outgoing.length - 1; + const linkPrefix = nextPrefix + (lastOutgoing ? '└─ ' : '├─ '); + lines.push(`${linkPrefix}${labelLink(outgoing.link)}`); + visit(outgoing.target, nextPrefix + (lastOutgoing ? ' ' : '│ '), true); + }); + } + + visit(tree, '', true); + return lines.join('\n'); +} diff --git a/src/gmailApi.js b/src/gmailApi.js new file mode 100644 index 0000000..9f338c8 --- /dev/null +++ b/src/gmailApi.js @@ -0,0 +1,368 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { google } from 'googleapis'; +import { archiveRawEmail, sha256 } from './mailArchive.js'; + +const GMAIL_READONLY_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; +const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'; +const DEFAULT_OAUTH_SCOPES = [GMAIL_MODIFY_SCOPE]; +const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:3912/oauth2callback'; + +export async function runGmailOAuthFlow({ account, redirectUri = DEFAULT_REDIRECT_URI, scopes = DEFAULT_OAUTH_SCOPES }) { + const oauth2Client = createOAuthClient({ account, redirectUri }); + const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: scopes, + }); + + const result = await waitForOAuthCallback({ authUrl, redirectUri, scopes }); + const { tokens } = await oauth2Client.getToken(result.code); + oauth2Client.setCredentials(tokens); + + const tokenPath = resolveTokenPath(account); + await writeToken(tokenPath, tokens); + + return { + accountId: account.id, + email: account.email, + tokenPath, + scopes, + authUrl, + }; +} + +export async function trashGmailMessages({ account, ids, gmail }) { + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('trashGmailMessages requires a non-empty ids array'); + } + const gmailClient = gmail || google.gmail({ + version: 'v1', + auth: await loadAuthorizedOAuthClient({ account }), + }); + const results = []; + for (const id of ids) { + try { + const response = await gmailClient.users.messages.trash({ userId: 'me', id }); + results.push({ + id, + trashed: true, + labelIds: response.data.labelIds || [], + }); + } catch (error) { + results.push({ + id, + trashed: false, + error: { message: error.message, code: error.code }, + }); + } + } + return { + accountId: account.id, + requested: ids.length, + trashed: results.filter((r) => r.trashed).length, + failed: results.filter((r) => !r.trashed).length, + results, + }; +} + +export async function downloadGmailApiAccount({ + account, + limit = 25, + since = account.defaultSince, + query, + archiveRoot, + includeSpamTrash = false, + gmail, +}) { + const gmailClient = gmail || google.gmail({ + version: 'v1', + auth: await loadAuthorizedOAuthClient({ account }), + }); + const q = query || buildGmailQuery({ since }); + const messageRefs = await listGmailMessageRefs({ + gmail: gmailClient, + q, + limit, + includeSpamTrash, + }); + + const root = archiveRoot || account.archive?.root || `data/mail-archive/${account.id}`; + const uidIndexDir = path.join(root, 'manifests', '.uid-index'); + await fs.mkdir(uidIndexDir, { recursive: true }); + + const records = []; + for (const ref of messageRefs) { + const uidMarker = path.join(uidIndexDir, sha256(ref.id)); + try { + await fs.access(uidMarker); + records.push({ id: ref.id, skipped: true, reason: 'already archived' }); + continue; + } catch { /* not yet archived, proceed */ } + + const response = await gmailClient.users.messages.get({ + userId: 'me', + id: ref.id, + format: 'raw', + }); + const raw = response.data.raw; + if (!raw) { + records.push({ id: ref.id, skipped: true, reason: 'missing raw message body' }); + continue; + } + + const record = await archiveRawEmail({ + account, + mailbox: 'gmail-api', + uid: ref.id, + source: decodeBase64Url(raw), + archiveRoot: root, + }); + await fs.writeFile(uidMarker, '', { flag: 'wx' }).catch(() => {}); + record.gmail = { + id: ref.id, + threadId: ref.threadId || response.data.threadId || null, + historyId: response.data.historyId || null, + internalDate: response.data.internalDate || null, + labelIds: response.data.labelIds || [], + }; + records.push(record); + } + + return { + accountId: account.id, + email: account.email, + query: q, + archiveRoot: root, + requested: Number(limit), + listed: messageRefs.length, + imported: records.filter((record) => !record.skipped).length, + records, + }; +} + +export async function listGmailMessageRefs({ gmail, q, limit = 25, includeSpamTrash = false }) { + const refs = []; + let pageToken; + while (refs.length < limit) { + const response = await gmail.users.messages.list({ + userId: 'me', + q, + includeSpamTrash, + maxResults: Math.min(500, limit - refs.length), + pageToken, + }); + refs.push(...(response.data.messages || [])); + pageToken = response.data.nextPageToken; + if (!pageToken) break; + } + return refs.slice(0, limit); +} + +export async function gmailEngagement({ account, sender, sample = 50, gmail }) { + if (!sender) throw new Error('gmailEngagement requires a sender'); + const gmailClient = gmail || google.gmail({ + version: 'v1', + auth: await loadAuthorizedOAuthClient({ account }), + }); + const q = `from:${sender}`; + const refs = await listGmailMessageRefs({ gmail: gmailClient, q, limit: sample, includeSpamTrash: false }); + let unread = 0; + for (const ref of refs) { + const meta = await gmailClient.users.messages.get({ + userId: 'me', + id: ref.id, + format: 'metadata', + metadataHeaders: ['From'], + }); + if ((meta.data.labelIds || []).includes('UNREAD')) unread += 1; + } + const total = refs.length; + const read = total - unread; + return { + sender, + sampled: total, + read, + unread, + readRate: total === 0 ? null : Number((read / total).toFixed(3)), + }; +} + +async function ensureGmailLabels({ gmail, names }) { + const existing = await gmail.users.labels.list({ userId: 'me' }); + const byName = new Map((existing.data.labels || []).map((l) => [l.name, l.id])); + const out = {}; + for (const name of names) { + if (byName.has(name)) { + out[name] = byName.get(name); + continue; + } + const created = await gmail.users.labels.create({ + userId: 'me', + requestBody: { + name, + labelListVisibility: 'labelShow', + messageListVisibility: 'show', + }, + }); + out[name] = created.data.id; + byName.set(name, created.data.id); + } + return out; +} + +export async function applyGmailLabels({ account, assignments, gmail }) { + if (!Array.isArray(assignments) || assignments.length === 0) { + throw new Error('applyGmailLabels requires a non-empty assignments array'); + } + const gmailClient = gmail || google.gmail({ + version: 'v1', + auth: await loadAuthorizedOAuthClient({ account }), + }); + const allNames = [...new Set(assignments.flatMap((a) => [ + ...(a.labels || []), + ...(a.removeLabels || []), + ]))]; + const labelIdByName = await ensureGmailLabels({ gmail: gmailClient, names: allNames }); + + const results = []; + for (const { id, labels = [], removeLabels = [] } of assignments) { + const addLabelIds = labels.map((name) => labelIdByName[name]).filter(Boolean); + const removeLabelIds = removeLabels.map((name) => labelIdByName[name]).filter(Boolean); + if (addLabelIds.length === 0 && removeLabelIds.length === 0) { + results.push({ id, applied: false, reason: 'no resolvable labels' }); + continue; + } + try { + const response = await gmailClient.users.messages.modify({ + userId: 'me', + id, + requestBody: { addLabelIds, removeLabelIds }, + }); + results.push({ + id, + applied: true, + labels, + removeLabels, + labelIds: response.data.labelIds || [], + }); + } catch (error) { + results.push({ + id, + applied: false, + labels, + removeLabels, + error: { message: error.message, code: error.code }, + }); + } + } + return { + accountId: account.id, + requested: assignments.length, + applied: results.filter((r) => r.applied).length, + failed: results.filter((r) => !r.applied).length, + labels: labelIdByName, + results, + }; +} + +export async function loadAuthorizedOAuthClient({ account, redirectUri = DEFAULT_REDIRECT_URI }) { + const oauth2Client = createOAuthClient({ account, redirectUri }); + const tokenPath = resolveTokenPath(account); + const tokens = JSON.parse(await fs.readFile(tokenPath, 'utf8')); + oauth2Client.setCredentials(tokens); + oauth2Client.on('tokens', async (newTokens) => { + await writeToken(tokenPath, { ...tokens, ...newTokens }); + }); + return oauth2Client; +} + +export function createOAuthClient({ account, redirectUri = DEFAULT_REDIRECT_URI }) { + const clientId = readOAuthValue(account.oauth?.clientIdEnv, 'GOOGLE_OAUTH_CLIENT_ID'); + const clientSecret = readOAuthValue(account.oauth?.clientSecretEnv, 'GOOGLE_OAUTH_CLIENT_SECRET', false); + if (!clientId) { + throw new Error(`missing OAuth client id. Set ${account.oauth?.clientIdEnv || 'GOOGLE_OAUTH_CLIENT_ID'}`); + } + return new google.auth.OAuth2(clientId, clientSecret || undefined, account.oauth?.redirectUri || redirectUri); +} + +export function buildGmailQuery({ since }) { + if (!since) return ''; + const date = new Date(since); + if (Number.isNaN(date.getTime())) throw new Error(`invalid since date: ${since}`); + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `after:${yyyy}/${mm}/${dd}`; +} + +export function decodeBase64Url(value) { + const normalized = String(value).replaceAll('-', '+').replaceAll('_', '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + return Buffer.from(padded, 'base64'); +} + +export function resolveTokenPath(account) { + return path.resolve(account.oauth?.tokenPath || `data/mail-oauth/${account.id}.token.json`); +} + +async function waitForOAuthCallback({ authUrl, redirectUri, scopes = DEFAULT_OAUTH_SCOPES }) { + const redirect = new URL(redirectUri); + if (!['127.0.0.1', 'localhost'].includes(redirect.hostname)) { + throw new Error(`local OAuth callback requires localhost redirect URI, got ${redirectUri}`); + } + + return new Promise((resolve, reject) => { + const server = http.createServer((request, response) => { + const requestUrl = new URL(request.url, redirectUri); + if (requestUrl.pathname !== redirect.pathname) { + response.writeHead(404); + response.end('Not found'); + return; + } + + const code = requestUrl.searchParams.get('code'); + const error = requestUrl.searchParams.get('error'); + if (error) { + response.writeHead(400, { 'Content-Type': 'text/plain' }); + response.end(`OAuth failed: ${error}`); + server.close(); + reject(new Error(`OAuth failed: ${error}`)); + return; + } + if (!code) { + response.writeHead(400, { 'Content-Type': 'text/plain' }); + response.end('Missing code'); + return; + } + + response.writeHead(200, { 'Content-Type': 'text/plain' }); + response.end('OAuth complete. You can close this tab.'); + server.close(); + resolve({ code }); + }); + + server.on('error', reject); + server.listen(Number(redirect.port), redirect.hostname, () => { + console.log(JSON.stringify({ + message: 'Open this URL in your browser and approve Gmail access (read + modify, includes trash/label).', + authUrl, + callback: redirectUri, + scopes, + }, null, 2)); + }); + }); +} + +async function writeToken(tokenPath, tokens) { + await fs.mkdir(path.dirname(tokenPath), { recursive: true }); + await fs.writeFile(tokenPath, `${JSON.stringify(tokens, null, 2)}\n`, { mode: 0o600 }); +} + +function readOAuthValue(primaryEnv, fallbackEnv, required = true) { + const envName = primaryEnv || fallbackEnv; + const value = process.env[envName] || (fallbackEnv && process.env[fallbackEnv]); + if (required && !value) return null; + return value || null; +} diff --git a/src/ledgerStore.js b/src/ledgerStore.js new file mode 100644 index 0000000..ca83619 --- /dev/null +++ b/src/ledgerStore.js @@ -0,0 +1,26 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { classifyAllMovements } from './classifier.js'; +import { normalizeLedger } from './domain.js'; + +export async function loadLedger(filePath) { + const absolutePath = path.resolve(filePath); + const rawText = await fs.readFile(absolutePath, 'utf8'); + const raw = JSON.parse(rawText); + const normalized = normalizeLedger(raw); + return classifyAllMovements(normalized); +} + +export function resolveAccountOwnerHints(ledger) { + const accountById = new Map(ledger.accounts.map((account) => [account.id, account])); + return { + ...ledger, + movements: ledger.movements.map((movement) => { + const account = accountById.get(movement.accountId); + return { + ...movement, + ownerEntityId: account?.ownerEntityId || movement.ownerEntityId || null, + }; + }), + }; +} diff --git a/src/localMailImport.js b/src/localMailImport.js new file mode 100644 index 0000000..ce8a5ae --- /dev/null +++ b/src/localMailImport.js @@ -0,0 +1,402 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { archiveRawEmail } from './mailArchive.js'; + +const DEFAULT_MAIL_ROOT = '/Users/kavi/Library/Mail/V10'; +const DEFAULT_ENVELOPE_INDEX = path.join(DEFAULT_MAIL_ROOT, 'MailData', 'Envelope Index'); +const DEFAULT_ACCOUNTS_DB = '/Users/kavi/Library/Accounts/Accounts4.sqlite'; +const execFileAsync = promisify(execFile); + +export async function listAppleMailSources(mailRoot = DEFAULT_MAIL_ROOT) { + const sources = []; + const accountDirs = await safeReaddir(mailRoot, { withFileTypes: true }); + + for (const accountDir of accountDirs.filter((entry) => entry.isDirectory())) { + if (accountDir.name === 'MailData') continue; + const accountPath = path.join(mailRoot, accountDir.name); + const mailboxes = await findMailboxDirs(accountPath); + if (!mailboxes.length) continue; + sources.push({ + accountDir: accountDir.name, + accountPath, + mailboxes: mailboxes.map((mailbox) => ({ + path: mailbox, + label: mailboxLabel(mailbox, accountPath), + })), + }); + } + + return sources; +} + +export async function importAppleMailEmlx({ account, sourcePath, archiveRoot, limit = 25, since }) { + const files = await findFiles(sourcePath, (file) => file.endsWith('.emlx') && !file.endsWith('.partial.emlx')); + const selectedFiles = []; + const sinceDate = since ? new Date(since) : null; + + for (const file of files.sort()) { + if (sinceDate) { + const stat = await fs.stat(file); + if (stat.mtime < sinceDate) continue; + } + selectedFiles.push(file); + if (selectedFiles.length >= limit) break; + } + + const records = []; + for (const file of selectedFiles) { + const source = await readEmlxRawMessage(file); + const record = await archiveRawEmail({ + account, + mailbox: mailboxLabel(file, sourcePath), + uid: path.basename(file, '.emlx'), + source, + archiveRoot, + }); + record.localSource = file; + records.push(record); + } + + return { + sourcePath, + archiveRoot, + imported: records.length, + scanned: files.length, + records, + }; +} + +export async function importAppleMailFromIndex({ + account, + email = account.email || account.auth?.user, + mailRoot = DEFAULT_MAIL_ROOT, + indexPath = DEFAULT_ENVELOPE_INDEX, + accountsDbPath = DEFAULT_ACCOUNTS_DB, + mailbox = 'all', + archiveRoot, + limit = 25, + since, +}) { + if (!email) throw new Error('email is required to resolve Apple Mail account'); + + const { imapAccount, mailboxRecord } = await resolveAppleMailAccountMailbox({ + email, + accountsDbPath, + indexPath, + mailbox, + }); + const sourcePath = mailboxPathForUrl(mailRoot, mailboxRecord.url); + const sinceTimestamp = since ? Math.floor(new Date(since).getTime() / 1000) : 0; + const importLimit = Number(limit); + const candidateLimit = Math.max(importLimit * 20, importLimit + 100); + + const messages = await queryJson(indexPath, ` + select m.ROWID as rowid, m.remote_id as remoteId, m.date_received as dateReceived + from messages m + where m.mailbox = ${Number(mailboxRecord.rowid)} + and m.deleted = 0 + and m.date_received >= ${Number.isFinite(sinceTimestamp) ? sinceTimestamp : 0} + order by m.date_received desc + limit ${Number.isFinite(candidateLimit) ? candidateLimit : 25} + `); + + const records = []; + const missing = []; + for (const message of messages) { + const localSource = await findEmlxByRowId(sourcePath, message.rowid); + if (!localSource) { + missing.push(message.rowid); + continue; + } + if (records.length >= importLimit) break; + + const source = await readEmlxRawMessage(localSource); + const record = await archiveRawEmail({ + account, + mailbox: decodeMailboxUrl(mailboxRecord.url), + uid: String(message.rowid), + source, + archiveRoot, + }); + record.localSource = localSource; + record.appleMail = { + accountIdentifier: imapAccount.identifier, + mailboxUrl: mailboxRecord.url, + rowid: message.rowid, + remoteId: message.remoteId, + dateReceived: message.dateReceived, + }; + records.push(record); + } + + return { + email, + imapAccount, + mailbox: mailboxRecord, + sourcePath, + archiveRoot, + imported: records.length, + scanned: messages.length, + missing, + records, + }; +} + +export async function resolveAppleMailAccountMailbox({ + email, + accountsDbPath = DEFAULT_ACCOUNTS_DB, + indexPath = DEFAULT_ENVELOPE_INDEX, + mailbox = 'all', +}) { + const imapAccounts = await resolveAppleMailImapAccounts({ email, accountsDbPath }); + const attempts = []; + + for (const imapAccount of imapAccounts) { + try { + const mailboxRecord = await resolveMailboxRecord({ indexPath, imapAccountId: imapAccount.identifier, mailbox }); + attempts.push({ imapAccount, mailboxRecord }); + } catch { + // Some macOS account records are stale or have no Mail mailbox. Try the next candidate. + } + } + + if (!attempts.length) { + throw new Error(`Apple Mail mailbox not found for ${email}/${mailbox}`); + } + + return attempts.sort((a, b) => Number(b.mailboxRecord.totalCount || 0) - Number(a.mailboxRecord.totalCount || 0))[0]; +} + +export async function resolveAppleMailImapAccount({ email, accountsDbPath = DEFAULT_ACCOUNTS_DB }) { + const rows = await resolveAppleMailImapAccounts({ email, accountsDbPath }); + if (!rows.length) { + throw new Error(`Apple Mail IMAP account not found for ${email}`); + } + return rows[0]; +} + +export async function resolveAppleMailImapAccounts({ email, accountsDbPath = DEFAULT_ACCOUNTS_DB }) { + const childRows = await queryJson(accountsDbPath, ` + select child.ZIDENTIFIER as identifier, + parent.ZUSERNAME as email, + parent.ZACCOUNTDESCRIPTION as description, + parent.ZIDENTIFIER as parentIdentifier, + 'child' as source + from ZACCOUNT parent + join ZACCOUNT child on child.ZPARENTACCOUNT = parent.Z_PK + join ZACCOUNTTYPE childType on child.ZACCOUNTTYPE = childType.Z_PK + where lower(parent.ZUSERNAME) = lower('${sqlString(email)}') + and childType.ZIDENTIFIER = 'com.apple.account.IMAP' + order by child.Z_PK + `); + + const directRows = await queryJson(accountsDbPath, ` + select account.ZIDENTIFIER as identifier, + account.ZUSERNAME as email, + account.ZACCOUNTDESCRIPTION as description, + null as parentIdentifier, + 'direct' as source + from ZACCOUNT account + join ZACCOUNTTYPE accountType on account.ZACCOUNTTYPE = accountType.Z_PK + where lower(account.ZUSERNAME) = lower('${sqlString(email)}') + and accountType.ZIDENTIFIER = 'com.apple.account.IMAP' + order by account.Z_PK + `); + + const rows = [...directRows, ...childRows]; + const unique = new Map(); + for (const row of rows) { + if (!unique.has(row.identifier)) unique.set(row.identifier, row); + } + + const result = [...unique.values()]; + if (!rows.length) { + throw new Error(`Apple Mail IMAP account not found for ${email}`); + } + return result; +} + +export async function resolveMailboxRecord({ indexPath = DEFAULT_ENVELOPE_INDEX, imapAccountId, mailbox = 'all' }) { + const accountPrefix = `imap://${imapAccountId}/`; + const candidates = mailboxCandidates(mailbox) + .map((name) => `${accountPrefix}${encodeMailboxPath(name)}`); + + for (const url of candidates) { + const rows = await queryJson(indexPath, ` + select ROWID as rowid, url, total_count as totalCount, unread_count as unreadCount, unseen_count as unseenCount + from mailboxes + where url = '${sqlString(url)}' + limit 1 + `); + if (rows.length) return rows[0]; + } + + const available = await queryJson(indexPath, ` + select ROWID as rowid, url, total_count as totalCount + from mailboxes + where url like '${sqlString(accountPrefix)}%' + order by total_count desc + limit 20 + `); + throw new Error(`Apple Mail mailbox not found for ${imapAccountId}/${mailbox}. Available: ${available.map((row) => row.url).join(', ')}`); +} + +export async function readEmlxRawMessage(filePath) { + const content = await fs.readFile(filePath); + const newlineIndex = content.indexOf(0x0a); + if (newlineIndex < 0) throw new Error(`invalid emlx file without first line: ${filePath}`); + + const sizeText = content.subarray(0, newlineIndex).toString('utf8').trim(); + const declaredSize = Number(sizeText); + if (!Number.isFinite(declaredSize) || declaredSize <= 0) { + return content.subarray(newlineIndex + 1); + } + + const start = newlineIndex + 1; + const end = Math.min(start + declaredSize, content.length); + return content.subarray(start, end); +} + +export async function findEmlxByRowId(sourcePath, rowid) { + const fileName = `${rowid}.emlx`; + const baseDirs = await mailDataBaseDirs(sourcePath); + const bucketParts = bucketPathParts(rowid); + + for (const baseDir of baseDirs) { + const candidate = path.join(baseDir, 'Data', ...bucketParts, 'Messages', fileName); + if (await fileExists(candidate)) return candidate; + } + + return findFirstFile(sourcePath, fileName); +} + +export function bucketPathParts(rowid) { + const bucket = Math.floor(Number(rowid) / 1000); + if (!bucket) return []; + return String(bucket).split('').reverse(); +} + +export function mailboxPathForUrl(mailRoot, url) { + const withoutScheme = url.replace(/^imap:\/\//, ''); + const slashIndex = withoutScheme.indexOf('/'); + const accountDir = slashIndex >= 0 ? withoutScheme.slice(0, slashIndex) : withoutScheme; + const mailboxPath = slashIndex >= 0 ? withoutScheme.slice(slashIndex + 1) : ''; + const mailboxParts = mailboxPath + .split('/') + .filter(Boolean) + .map((part) => decodeURIComponent(part)); + + return path.join(mailRoot, accountDir, ...mailboxParts.map((part) => `${part}.mbox`)); +} + +export function decodeMailboxUrl(url) { + const withoutScheme = url.replace(/^imap:\/\//, ''); + const slashIndex = withoutScheme.indexOf('/'); + const mailboxPath = slashIndex >= 0 ? withoutScheme.slice(slashIndex + 1) : ''; + return mailboxPath + .split('/') + .filter(Boolean) + .map((part) => decodeURIComponent(part)) + .join('/'); +} + +async function findMailboxDirs(root) { + const dirs = []; + const entries = await safeReaddir(root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const fullPath = path.join(root, entry.name); + if (entry.name.endsWith('.mbox')) dirs.push(fullPath); + const nested = await findMailboxDirs(fullPath); + dirs.push(...nested); + } + return dirs; +} + +async function findFiles(root, predicate) { + const found = []; + const entries = await safeReaddir(root, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + found.push(...await findFiles(fullPath, predicate)); + } else if (entry.isFile() && predicate(fullPath)) { + found.push(fullPath); + } + } + return found; +} + +async function findFirstFile(root, fileName) { + const entries = await safeReaddir(root, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(root, entry.name); + if (entry.isFile() && entry.name === fileName) return fullPath; + if (entry.isDirectory()) { + const nested = await findFirstFile(fullPath, fileName); + if (nested) return nested; + } + } + return null; +} + +async function mailDataBaseDirs(sourcePath) { + const baseDirs = [sourcePath]; + const entries = await safeReaddir(sourcePath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const fullPath = path.join(sourcePath, entry.name); + if (await fileExists(path.join(fullPath, 'Data'))) baseDirs.push(fullPath); + } + return baseDirs; +} + +async function queryJson(dbPath, sql) { + const { stdout } = await execFileAsync('sqlite3', ['-json', dbPath, sql], { + maxBuffer: 1024 * 1024 * 20, + }); + return stdout.trim() ? JSON.parse(stdout) : []; +} + +function mailboxCandidates(mailbox) { + if (!mailbox || mailbox === 'all') return ['[Gmail]/Todos', '[Gmail]/All Mail', 'INBOX']; + if (mailbox === 'inbox') return ['INBOX']; + return [mailbox]; +} + +function encodeMailboxPath(mailboxPath) { + return mailboxPath + .split('/') + .map((part) => encodeURIComponent(part)) + .join('/'); +} + +function sqlString(value) { + return String(value).replaceAll("'", "''"); +} + +async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function safeReaddir(dir, options) { + try { + return await fs.readdir(dir, options); + } catch { + return []; + } +} + +function mailboxLabel(itemPath, rootPath) { + return path.relative(rootPath, itemPath) + .split(path.sep) + .filter((part) => part && part !== 'Data' && part !== 'Messages') + .join('/'); +} diff --git a/src/mailArchive.js b/src/mailArchive.js new file mode 100644 index 0000000..773166c --- /dev/null +++ b/src/mailArchive.js @@ -0,0 +1,123 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { simpleParser } from 'mailparser'; + +export function sha256(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +export function safeFileName(input) { + return String(input || 'unnamed') + .normalize('NFKD') + .replace(/[^\w.\-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 180) || 'unnamed'; +} + +export function dateParts(dateLike) { + const date = dateLike ? new Date(dateLike) : new Date(); + if (Number.isNaN(date.getTime())) return dateParts(new Date()); + return { + yyyy: String(date.getUTCFullYear()), + mm: String(date.getUTCMonth() + 1).padStart(2, '0'), + dd: String(date.getUTCDate()).padStart(2, '0'), + iso: date.toISOString(), + }; +} + +export async function archiveRawEmail({ account, mailbox, uid, source, archiveRoot }) { + const rawBuffer = Buffer.isBuffer(source) ? source : Buffer.from(source); + const hash = sha256(rawBuffer); + const parsed = await simpleParser(rawBuffer); + const parts = dateParts(parsed.date); + const rawDir = path.join(archiveRoot, 'raw-eml', parts.yyyy, parts.mm); + await fs.mkdir(rawDir, { recursive: true }); + + const emlName = `${parts.dd}-${uid || 'no-uid'}-${hash.slice(0, 16)}.eml`; + const emlPath = path.join(rawDir, emlName); + await writeFileIfMissing(emlPath, rawBuffer); + + const attachmentRecords = await archiveAttachments({ + parsed, + archiveRoot, + dateParts: parts, + emailHash: hash, + }); + + const record = { + id: `mail:${account.id}:${mailbox}:${uid || hash.slice(0, 16)}`, + accountId: account.id, + mailbox, + uid: uid || null, + messageId: parsed.messageId || null, + date: parts.iso, + from: parsed.from?.text || null, + to: parsed.to?.text || null, + subject: parsed.subject || null, + raw: { + path: emlPath, + sha256: hash, + size: rawBuffer.length, + }, + attachments: attachmentRecords, + textPreview: (parsed.text || '').replace(/\s+/g, ' ').trim().slice(0, 500), + archivedAt: new Date().toISOString(), + }; + + record.manifestAppended = await appendManifest(archiveRoot, 'emails.ndjson', record); + return record; +} + +export async function archiveAttachments({ parsed, archiveRoot, dateParts: parts, emailHash }) { + const attachments = []; + const attachDir = path.join(archiveRoot, 'attachments', parts.yyyy, parts.mm); + await fs.mkdir(attachDir, { recursive: true }); + + for (const attachment of parsed.attachments || []) { + const content = Buffer.from(attachment.content || []); + const hash = sha256(content); + const filename = safeFileName(attachment.filename || `attachment-${hash.slice(0, 8)}`); + const storedName = `${hash.slice(0, 16)}-${filename}`; + const storedPath = path.join(attachDir, storedName); + await writeFileIfMissing(storedPath, content); + + attachments.push({ + filename: attachment.filename || null, + contentType: attachment.contentType || null, + size: content.length, + sha256: hash, + path: storedPath, + emailSha256: emailHash, + }); + } + + return attachments; +} + +export async function appendManifest(archiveRoot, name, record) { + const manifestDir = path.join(archiveRoot, 'manifests'); + await fs.mkdir(manifestDir, { recursive: true }); + const markerDir = path.join(manifestDir, '.index', safeFileName(name)); + await fs.mkdir(markerDir, { recursive: true }); + + const markerKey = sha256(`${record.id}|${record.raw?.sha256 || ''}`); + const markerPath = path.join(markerDir, markerKey); + try { + await fs.writeFile(markerPath, new Date().toISOString(), { flag: 'wx' }); + } catch (error) { + if (error.code === 'EEXIST') return false; + throw error; + } + + await fs.appendFile(path.join(manifestDir, name), `${JSON.stringify(record)}\n`, 'utf8'); + return true; +} + +async function writeFileIfMissing(filePath, content) { + try { + await fs.access(filePath); + } catch { + await fs.writeFile(filePath, content); + } +} diff --git a/src/mailCli.js b/src/mailCli.js new file mode 100644 index 0000000..dfd7072 --- /dev/null +++ b/src/mailCli.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import { archiveRawEmail } from './mailArchive.js'; +import { downloadAccount, dryRunAccount } from './mailDownloader.js'; +import { downloadGmailApiAccount, runGmailOAuthFlow, trashGmailMessages, gmailEngagement, applyGmailLabels } from './gmailApi.js'; +import { getMailAccount } from './mailConfig.js'; +import { importAppleMailEmlx, importAppleMailFromIndex, listAppleMailSources } from './localMailImport.js'; +import { importMbox } from './mboxImport.js'; + +const [command, ...rest] = process.argv.slice(2); +const args = parseArgs(rest); + +try { + if (command === 'dry-run') { + const account = await getMailAccount(required(args.account, '--account')); + const result = await dryRunAccount(account); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'download') { + const account = await getMailAccount(required(args.account, '--account')); + const result = await downloadAccount(account, { + limit: args.limit ? Number(args.limit) : 25, + since: args.since, + archiveRoot: args.archiveRoot, + }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'gmail-oauth') { + const account = await getMailAccount(required(args.account, '--account')); + const result = await runGmailOAuthFlow({ + account, + redirectUri: args.redirectUri, + }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'gmail-download') { + const account = await getMailAccount(required(args.account, '--account')); + const result = await downloadGmailApiAccount({ + account, + limit: args.limit ? Number(args.limit) : 25, + since: args.since || account.defaultSince, + query: args.query, + includeSpamTrash: parseBoolean(args.includeSpamTrash), + archiveRoot: args.archiveRoot || account.archive?.root || `data/mail-archive/${account.id}`, + }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'gmail-engagement') { + const account = await getMailAccount(required(args.account, '--account')); + const sender = required(args.sender, '--sender'); + const result = await gmailEngagement({ + account, + sender, + sample: args.sample ? Number(args.sample) : 50, + }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'gmail-apply-labels') { + const account = await getMailAccount(required(args.account, '--account')); + const filePath = required(args.file, '--file'); + const raw = await fs.readFile(filePath, 'utf8'); + let assignments; + try { + const parsed = JSON.parse(raw); + assignments = Array.isArray(parsed) ? parsed : parsed.assignments; + } catch (error) { + throw new Error(`could not parse JSON at ${filePath}: ${error.message}`); + } + if (!Array.isArray(assignments)) { + throw new Error('--file must contain an array or {assignments:[...]} of {id,labels}'); + } + if (parseBoolean(args.dryRun ?? args['dry-run'])) { + console.log(JSON.stringify({ + accountId: account.id, + dryRun: true, + assignments, + uniqueLabels: [...new Set(assignments.flatMap((a) => a.labels || []))].sort(), + }, null, 2)); + } else { + const result = await applyGmailLabels({ account, assignments }); + console.log(JSON.stringify(result, null, 2)); + } + } else if (command === 'gmail-trash') { + const account = await getMailAccount(required(args.account, '--account')); + const ids = parseIdList(args.id, args.ids); + if (ids.length === 0) throw new Error('gmail-trash requires --id or --ids '); + const result = await trashGmailMessages({ account, ids }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'parse-fixture') { + const fixturePath = rest.find((value) => !value.startsWith('--')); + if (!fixturePath) throw new Error('parse-fixture requires an .eml path'); + const account = await getMailAccount(required(args.account, '--account')); + const source = await fs.readFile(fixturePath); + const result = await archiveRawEmail({ + account, + mailbox: 'fixture', + uid: 'fixture', + source, + archiveRoot: args.archiveRoot || account.archive?.root || `data/mail-archive/${account.id}`, + }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'list-apple-mail') { + const result = await listAppleMailSources(args.root); + console.log(JSON.stringify({ sources: result }, null, 2)); + } else if (command === 'import-apple-mail') { + const account = await getMailAccount(required(args.account, '--account')); + const sourcePath = required(args.source, '--source'); + const result = await importAppleMailEmlx({ + account, + sourcePath, + limit: args.limit ? Number(args.limit) : 25, + since: args.since, + archiveRoot: args.archiveRoot || account.archive?.root || `data/mail-archive/${account.id}`, + }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'import-apple-mail-index') { + const account = await getMailAccount(required(args.account, '--account')); + const result = await importAppleMailFromIndex({ + account, + email: args.email || account.email || account.auth?.user, + mailRoot: args.root, + indexPath: args.index, + accountsDbPath: args.accountsDb, + mailbox: args.mailbox || 'all', + limit: args.limit ? Number(args.limit) : 25, + since: args.since || account.defaultSince, + archiveRoot: args.archiveRoot || account.archive?.root || `data/mail-archive/${account.id}`, + }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === 'import-mbox') { + const account = await getMailAccount(required(args.account, '--account')); + const mboxPath = required(args.file, '--file'); + const result = await importMbox({ + account, + mboxPath, + limit: args.limit ? Number(args.limit) : 25, + archiveRoot: args.archiveRoot || account.archive?.root || `data/mail-archive/${account.id}`, + }); + console.log(JSON.stringify(result, null, 2)); + } else { + usage(); + process.exit(1); + } +} catch (error) { + console.error(formatError(error)); + process.exit(1); +} + +function parseArgs(values) { + const parsed = {}; + for (let i = 0; i < values.length; i += 1) { + const value = values[i]; + if (!value.startsWith('--')) continue; + parsed[value.slice(2)] = values[i + 1]; + i += 1; + } + return parsed; +} + +function required(value, name) { + if (!value) throw new Error(`${name} is required`); + return value; +} + +function parseIdList(single, csv) { + const ids = []; + if (single) ids.push(...String(single).split(',').map((v) => v.trim()).filter(Boolean)); + if (csv) ids.push(...String(csv).split(',').map((v) => v.trim()).filter(Boolean)); + return [...new Set(ids)]; +} + +function parseBoolean(value) { + if (value === undefined) return false; + return ['1', 'true', 'yes', 'y'].includes(String(value).toLowerCase()); +} + +function usage() { + console.error(`Usage: + node src/mailCli.js dry-run --account vjoati-gmail + node src/mailCli.js download --account vjoati-gmail [--since 2025-01-01] [--limit 25] + node src/mailCli.js gmail-oauth --account vjoati-gmail + node src/mailCli.js gmail-download --account vjoati-gmail [--since 2025-01-01] [--limit 25] + node src/mailCli.js gmail-trash --account vjoati-gmail (--id | --ids id1,id2,...) + node src/mailCli.js gmail-engagement --account vjoati-gmail --sender [--sample 50] + node src/mailCli.js gmail-apply-labels --account vjoati-gmail --file [--dry-run true] + node src/mailCli.js parse-fixture --account vjoati-gmail + node src/mailCli.js list-apple-mail + node src/mailCli.js import-apple-mail --account vjoati-gmail --source /Users/kavi/Library/Mail/V10/... --limit 25 + node src/mailCli.js import-apple-mail-index --account vjoati-gmail [--mailbox all] [--since 2025-01-01] [--limit 25] + node src/mailCli.js import-mbox --account vjoati-gmail --file /path/to/takeout.mbox --limit 25`); +} + +function formatError(error) { + const details = { + message: error.message, + name: error.name, + code: error.code, + response: error.response, + responseText: error.responseText, + authenticationFailed: error.authenticationFailed, + }; + return JSON.stringify( + Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined)), + null, + 2 + ); +} diff --git a/src/mailConfig.js b/src/mailConfig.js new file mode 100644 index 0000000..3c9dfbc --- /dev/null +++ b/src/mailConfig.js @@ -0,0 +1,30 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const DEFAULT_CONFIG = 'config/mail-accounts.json'; + +export async function loadMailConfig(configPath = DEFAULT_CONFIG) { + const absolutePath = path.resolve(configPath); + const raw = JSON.parse(await fs.readFile(absolutePath, 'utf8')); + if (!Array.isArray(raw.accounts)) throw new Error('mail config must include accounts[]'); + return raw; +} + +export async function getMailAccount(accountId, configPath = DEFAULT_CONFIG) { + const config = await loadMailConfig(configPath); + const account = config.accounts.find((candidate) => candidate.id === accountId); + if (!account) throw new Error(`mail account not found: ${accountId}`); + return account; +} + +export function requireAccountPassword(account) { + const envName = account.auth?.passwordEnv; + if (!envName) throw new Error(`account ${account.id} missing auth.passwordEnv`); + const password = process.env[envName]; + if (!password) { + throw new Error( + `missing ${envName}. For Gmail use an app password or OAuth token; do not store it in the repo.` + ); + } + return password; +} diff --git a/src/mailDownloader.js b/src/mailDownloader.js new file mode 100644 index 0000000..20675a4 --- /dev/null +++ b/src/mailDownloader.js @@ -0,0 +1,87 @@ +import { ImapFlow } from 'imapflow'; +import { archiveRawEmail } from './mailArchive.js'; +import { requireAccountPassword } from './mailConfig.js'; + +export async function dryRunAccount(account) { + const password = requireAccountPassword(account); + const client = makeClient(account, password); + await client.connect(); + try { + const mailboxes = await client.list(); + const selected = []; + for (const mailbox of mailboxes) { + if (mailbox.flags?.has?.('\\Noselect')) continue; + if (account.mailboxes?.length && !account.mailboxes.includes(mailbox.path)) continue; + const status = await client.status(mailbox.path, { messages: true, unseen: true, uidNext: true }); + selected.push({ + path: mailbox.path, + messages: status.messages, + unseen: status.unseen, + uidNext: status.uidNext, + }); + } + return { accountId: account.id, email: account.email, mailboxes: selected }; + } finally { + await client.logout().catch(() => {}); + } +} + +export async function downloadAccount(account, options = {}) { + const password = requireAccountPassword(account); + const client = makeClient(account, password); + const limit = Number(options.limit || 25); + const since = options.since || account.defaultSince || null; + const archiveRoot = options.archiveRoot || account.archive?.root || `data/mail-archive/${account.id}`; + const results = []; + + await client.connect(); + try { + const mailboxes = account.mailboxes?.length ? account.mailboxes : ['INBOX']; + for (const mailbox of mailboxes) { + const lock = await client.getMailboxLock(mailbox); + try { + const criteria = since ? { since: new Date(since) } : { all: true }; + const uids = await client.search(criteria, { uid: true }); + const selectedUids = uids.slice(Math.max(0, uids.length - limit)); + + for await (const msg of client.fetch(selectedUids, { uid: true, source: true }, { uid: true })) { + const record = await archiveRawEmail({ + account, + mailbox, + uid: msg.uid, + source: msg.source, + archiveRoot, + }); + results.push(record); + } + } finally { + lock.release(); + } + } + } finally { + await client.logout().catch(() => {}); + } + + return { + accountId: account.id, + archiveRoot, + downloaded: results.length, + records: results, + }; +} + +function makeClient(account, password) { + return new ImapFlow({ + host: account.host, + port: account.port || 993, + secure: account.secure !== false, + auth: { + user: account.auth?.user || account.email, + pass: password, + }, + logger: false, + tls: { + rejectUnauthorized: true, + }, + }); +} diff --git a/src/mboxImport.js b/src/mboxImport.js new file mode 100644 index 0000000..31f58e4 --- /dev/null +++ b/src/mboxImport.js @@ -0,0 +1,56 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { archiveRawEmail } from './mailArchive.js'; + +export async function importMbox({ account, mboxPath, archiveRoot, limit = 25 }) { + const content = await fs.readFile(mboxPath); + const messages = splitMbox(content); + const selected = messages.slice(0, limit); + const records = []; + + for (let index = 0; index < selected.length; index += 1) { + const record = await archiveRawEmail({ + account, + mailbox: `mbox:${path.basename(mboxPath)}`, + uid: `mbox-${index + 1}`, + source: selected[index], + archiveRoot, + }); + record.localSource = mboxPath; + records.push(record); + } + + return { + mboxPath, + archiveRoot, + imported: records.length, + scanned: messages.length, + records, + }; +} + +export function splitMbox(buffer) { + const text = buffer.toString('binary'); + const starts = []; + + if (text.startsWith('From ')) starts.push(0); + let position = 0; + while (true) { + const next = text.indexOf('\nFrom ', position); + if (next === -1) break; + starts.push(next + 1); + position = next + 1; + } + + if (!starts.length) return [buffer]; + + const messages = []; + for (let i = 0; i < starts.length; i += 1) { + const start = starts[i]; + const end = starts[i + 1] || buffer.length; + const chunk = buffer.subarray(start, end); + const firstNewline = chunk.indexOf(0x0a); + if (firstNewline >= 0) messages.push(chunk.subarray(firstNewline + 1)); + } + return messages.filter((message) => message.length > 0); +} diff --git a/src/moneyGraph.js b/src/moneyGraph.js new file mode 100644 index 0000000..c18c8c2 --- /dev/null +++ b/src/moneyGraph.js @@ -0,0 +1,236 @@ +import { + ECONOMIC_TYPES, + LINK_METHODS, + LINK_TYPES, + buildNodeIndex, + compareDateThenId, +} from './domain.js'; + +function makeLink({ from, to, type, amount, method, confidence = 'rule', state = 'proposed', note }) { + return { from, to, type, amount, method, confidence, state, note }; +} + +export function buildMoneyGraph(ledger) { + const nodes = buildNodeIndex(ledger); + const links = [...ledger.links]; + + links.push(...inferAccountFifoFundingLinks(ledger, links)); + links.push(...inferCardSettlementLinks(ledger, links)); + + return { + nodes, + links, + incoming: indexLinks(links, 'to'), + outgoing: indexLinks(links, 'from'), + }; +} + +function indexLinks(links, field) { + const index = new Map(); + for (const link of links) { + if (!index.has(link[field])) index.set(link[field], []); + index.get(link[field]).push(link); + } + return index; +} + +function inferAccountFifoFundingLinks(ledger, existingLinks) { + const movementsByAccountCurrency = new Map(); + const hasIncomingFunding = new Set( + existingLinks + .filter((link) => [LINK_TYPES.FUNDS, LINK_TYPES.INTERNAL_TRANSFER, LINK_TYPES.FX_CONVERSION].includes(link.type)) + .map((link) => link.to) + ); + + for (const movement of ledger.movements) { + const key = `${movement.accountId}|${movement.amount.currency}`; + if (!movementsByAccountCurrency.has(key)) movementsByAccountCurrency.set(key, []); + movementsByAccountCurrency.get(key).push(movement); + } + + const inferred = []; + + for (const movements of movementsByAccountCurrency.values()) { + movements.sort(compareDateThenId); + const lots = []; + + for (const movement of movements) { + if (movement.direction === 'in') { + if (isFundingLot(movement)) { + lots.push({ movement, remaining: movement.amount.value }); + } + continue; + } + + if (movement.direction !== 'out') continue; + if (!isCashOutflowNeedingOrigin(movement)) continue; + if (hasIncomingFunding.has(movement.id)) continue; + + let needed = movement.amount.value; + for (const lot of lots) { + if (needed <= 0) break; + if (lot.remaining <= 0) continue; + const used = Math.min(lot.remaining, needed); + inferred.push(makeLink({ + from: lot.movement.id, + to: movement.id, + type: LINK_TYPES.FUNDS, + amount: { currency: movement.amount.currency, value: used }, + method: LINK_METHODS.RULE_FIFO, + note: 'FIFO por cuenta y moneda: ingreso previo financia salida posterior.', + })); + lot.remaining -= used; + needed -= used; + } + } + } + + return inferred; +} + +function isFundingLot(movement) { + return [ + ECONOMIC_TYPES.PURE_INCOME, + ECONOMIC_TYPES.OPERATING_INCOME, + ECONOMIC_TYPES.REFUND, + ECONOMIC_TYPES.REIMBURSEMENT, + ECONOMIC_TYPES.PARTNER_LOAN, + ECONOMIC_TYPES.INTERNAL_TRANSFER, + ECONOMIC_TYPES.FOREIGN_ACCOUNT_FUNDING, + ECONOMIC_TYPES.WALLET_FUNDING, + ].includes(movement.economicType); +} + +function isCashOutflowNeedingOrigin(movement) { + return [ + ECONOMIC_TYPES.REAL_EXPENSE, + ECONOMIC_TYPES.CARD_PAYMENT, + ECONOMIC_TYPES.WALLET_FUNDING, + ECONOMIC_TYPES.FOREIGN_ACCOUNT_FUNDING, + ECONOMIC_TYPES.INTERNAL_TRANSFER, + ECONOMIC_TYPES.REIMBURSEMENT, + ECONOMIC_TYPES.PARTNER_WITHDRAWAL, + ].includes(movement.economicType); +} + +function inferCardSettlementLinks(ledger, existingLinks) { + const movementsByAccountCurrency = new Map(); + const manuallySettledCharges = new Set( + existingLinks + .filter((link) => link.type === LINK_TYPES.SETTLES_CARD_CHARGE) + .map((link) => link.to) + ); + + for (const movement of ledger.movements) { + if (!movement.cardAccountId && movement.economicType !== ECONOMIC_TYPES.CARD_CHARGE) continue; + const accountId = movement.cardAccountId || movement.accountId; + const key = `${accountId}|${movement.amount.currency}`; + if (!movementsByAccountCurrency.has(key)) movementsByAccountCurrency.set(key, []); + movementsByAccountCurrency.get(key).push(movement); + } + + const inferred = []; + + for (const movements of movementsByAccountCurrency.values()) { + const charges = movements + .filter((movement) => movement.economicType === ECONOMIC_TYPES.CARD_CHARGE && !manuallySettledCharges.has(movement.id)) + .sort(compareDateThenId) + .map((movement) => ({ movement, remaining: movement.amount.value })); + + const payments = movements + .filter((movement) => movement.economicType === ECONOMIC_TYPES.CARD_PAYMENT) + .sort(compareDateThenId); + + for (const payment of payments) { + let remainingPayment = payment.amount.value; + for (const charge of charges) { + if (remainingPayment <= 0) break; + if (charge.remaining <= 0) continue; + if (String(charge.movement.date) > String(payment.date)) continue; + + const settled = Math.min(charge.remaining, remainingPayment); + inferred.push(makeLink({ + from: payment.id, + to: charge.movement.id, + type: LINK_TYPES.SETTLES_CARD_CHARGE, + amount: { currency: payment.amount.currency, value: settled }, + method: LINK_METHODS.RULE_FIFO, + note: 'Pago de tarjeta liquida cargos anteriores por FIFO dentro de la misma tarjeta y moneda.', + })); + charge.remaining -= settled; + remainingPayment -= settled; + } + } + } + + return inferred; +} + +export function originTree(graph, nodeId, options = {}) { + const maxDepth = options.maxDepth ?? 20; + const seen = new Set(); + + function walk(id, depth) { + const node = graph.nodes.get(id) || { id, kind: 'unknown', description: 'Nodo no encontrado' }; + if (depth >= maxDepth) return { node, truncated: true, incoming: [] }; + if (seen.has(id)) return { node, cycle: true, incoming: [] }; + + seen.add(id); + const incoming = (graph.incoming.get(id) || []).map((link) => ({ + link, + source: walk(link.from, depth + 1), + })); + seen.delete(id); + + return { node, incoming }; + } + + return walk(nodeId, 0); +} + +export function destinationTree(graph, nodeId, options = {}) { + const maxDepth = options.maxDepth ?? 20; + const seen = new Set(); + + function walk(id, depth) { + const node = graph.nodes.get(id) || { id, kind: 'unknown', description: 'Nodo no encontrado' }; + if (depth >= maxDepth) return { node, truncated: true, outgoing: [] }; + if (seen.has(id)) return { node, cycle: true, outgoing: [] }; + + seen.add(id); + const outgoing = (graph.outgoing.get(id) || []).map((link) => ({ + link, + target: walk(link.to, depth + 1), + })); + seen.delete(id); + + return { node, outgoing }; + } + + return walk(nodeId, 0); +} + +export function summarizeLedger(ledger, graph) { + const byType = new Map(); + const byEntity = new Map(); + + for (const movement of ledger.movements) { + const key = movement.economicType || ECONOMIC_TYPES.UNKNOWN; + byType.set(key, (byType.get(key) || 0) + movement.amount.value); + const entityKey = movement.beneficiaryEntityId || movement.ownerEntityId || 'sin_entidad'; + byEntity.set(entityKey, (byEntity.get(entityKey) || 0) + movement.amount.value); + } + + return { + counts: { + entities: ledger.entities.length, + accounts: ledger.accounts.length, + movements: ledger.movements.length, + documents: ledger.documents.length, + events: ledger.events.length, + links: graph.links.length, + }, + movementAmountByType: Object.fromEntries([...byType.entries()].sort()), + movementAmountByEntityHint: Object.fromEntries([...byEntity.entries()].sort()), + }; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..d2d196d --- /dev/null +++ b/src/server.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import http from 'node:http'; +import { URL } from 'node:url'; +import { buildMoneyGraph, destinationTree, originTree, summarizeLedger } from './moneyGraph.js'; +import { loadLedger, resolveAccountOwnerHints } from './ledgerStore.js'; + +const args = parseArgs(process.argv.slice(2)); +const ledgerPath = args.ledger || 'data/example-ledger.json'; +const port = Number(args.port || 3910); +const host = args.host || '127.0.0.1'; + +let ledger = resolveAccountOwnerHints(await loadLedger(ledgerPath)); +let graph = buildMoneyGraph(ledger); + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === 'GET' && url.pathname === '/health') { + return sendJson(res, { status: 'ok', service: 'kua-money-trace', ledger: ledgerPath }); + } + + if (req.method === 'POST' && url.pathname === '/reload') { + ledger = resolveAccountOwnerHints(await loadLedger(ledgerPath)); + graph = buildMoneyGraph(ledger); + return sendJson(res, { reloaded: true, summary: summarizeLedger(ledger, graph) }); + } + + if (req.method === 'GET' && url.pathname === '/summary') { + return sendJson(res, summarizeLedger(ledger, graph)); + } + + if (req.method === 'GET' && url.pathname === '/entities') { + return sendJson(res, { entities: ledger.entities }); + } + + if (req.method === 'GET' && url.pathname === '/accounts') { + return sendJson(res, { accounts: ledger.accounts }); + } + + if (req.method === 'GET' && url.pathname === '/movements') { + return sendJson(res, { movements: ledger.movements }); + } + + const originMatch = url.pathname.match(/^\/nodes\/(.+)\/origin-tree$/); + if (req.method === 'GET' && originMatch) { + return sendJson(res, originTree(graph, decodeURIComponent(originMatch[1]))); + } + + const destinationMatch = url.pathname.match(/^\/nodes\/(.+)\/destination-tree$/); + if (req.method === 'GET' && destinationMatch) { + return sendJson(res, destinationTree(graph, decodeURIComponent(destinationMatch[1]))); + } + + sendJson(res, { error: 'not found' }, 404); + } catch (error) { + sendJson(res, { error: error.message }, 500); + } +}); + +server.listen(port, host, () => { + console.log(`kua-money-trace listening on http://${host}:${port}`); +}); + +function sendJson(res, body, status = 200) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(body, null, 2)); +} + +function parseArgs(argv) { + const parsed = {}; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i].startsWith('--')) { + parsed[argv[i].slice(2)] = argv[i + 1]; + i += 1; + } + } + return parsed; +} diff --git a/test/fixtures/sample-bank-email.eml b/test/fixtures/sample-bank-email.eml new file mode 100644 index 0000000..70ad100 --- /dev/null +++ b/test/fixtures/sample-bank-email.eml @@ -0,0 +1,20 @@ +From: Banco Demo +To: vjoati@gmail.com +Subject: Estado de cuenta tarjeta credito terminado 5018 +Date: Tue, 30 Dec 2025 10:30:00 -0300 +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary-demo" + +--boundary-demo +Content-Type: text/plain; charset="UTF-8" + +Adjuntamos estado de cuenta de tarjeta de credito terminada 5018. + +--boundary-demo +Content-Type: application/pdf; name="estado-cuenta-5018.pdf" +Content-Disposition: attachment; filename="estado-cuenta-5018.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyA+PgplbmRvYmoKdHJhaWxlcgo8PCAvUm9vdCAxIDAgUiA+PgolJUVPRgo= +--boundary-demo-- diff --git a/test/gmailApi.test.js b/test/gmailApi.test.js new file mode 100644 index 0000000..34639d1 --- /dev/null +++ b/test/gmailApi.test.js @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + buildGmailQuery, + decodeBase64Url, + downloadGmailApiAccount, + listGmailMessageRefs, +} from '../src/gmailApi.js'; + +const sample = await fs.readFile('test/fixtures/sample-bank-email.eml'); + +test('builds gmail after query from ISO date', () => { + assert.equal(buildGmailQuery({ since: '2025-01-01' }), 'after:2025/01/01'); +}); + +test('decodes gmail base64url raw message', () => { + const encoded = sample.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + assert.equal(decodeBase64Url(encoded).toString(), sample.toString()); +}); + +test('lists gmail message refs across pages', async () => { + const calls = []; + const gmail = { + users: { + messages: { + async list(params) { + calls.push(params); + if (!params.pageToken) { + return { data: { messages: [{ id: 'a' }], nextPageToken: 'next' } }; + } + return { data: { messages: [{ id: 'b' }] } }; + }, + }, + }, + }; + + const refs = await listGmailMessageRefs({ gmail, q: 'after:2025/01/01', limit: 2 }); + assert.deepEqual(refs.map((ref) => ref.id), ['a', 'b']); + assert.equal(calls[1].pageToken, 'next'); +}); + +test('downloads gmail raw messages into archive', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-gmail-api-')); + const encoded = sample.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + const gmail = { + users: { + messages: { + async list() { + return { data: { messages: [{ id: 'gmail-message-1', threadId: 'thread-1' }] } }; + }, + async get(params) { + assert.equal(params.format, 'raw'); + return { + data: { + id: params.id, + threadId: 'thread-1', + historyId: 'history-1', + internalDate: '1764547200000', + labelIds: ['INBOX'], + raw: encoded, + }, + }; + }, + }, + }, + }; + + const result = await downloadGmailApiAccount({ + account: { id: 'vjoati-gmail', email: 'vjoati@gmail.com' }, + gmail, + archiveRoot: tmp, + since: '2025-01-01', + limit: 1, + }); + + assert.equal(result.imported, 1); + assert.equal(result.records[0].gmail.id, 'gmail-message-1'); + assert.equal(result.records[0].attachments.length, 1); +}); diff --git a/test/localMailImport.test.js b/test/localMailImport.test.js new file mode 100644 index 0000000..988f476 --- /dev/null +++ b/test/localMailImport.test.js @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; +import { + bucketPathParts, + importAppleMailEmlx, + importAppleMailFromIndex, + mailboxPathForUrl, + readEmlxRawMessage, +} from '../src/localMailImport.js'; +import { importMbox, splitMbox } from '../src/mboxImport.js'; + +const sample = await fs.readFile('test/fixtures/sample-bank-email.eml'); +const execFileAsync = promisify(execFile); + +test('reads raw message from emlx declared byte size', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-emlx-')); + const emlxPath = path.join(tmp, '1.emlx'); + await fs.writeFile(emlxPath, Buffer.concat([ + Buffer.from(`${sample.length}\n`, 'utf8'), + sample, + Buffer.from('\n\n', 'utf8'), + ])); + + const raw = await readEmlxRawMessage(emlxPath); + assert.equal(raw.toString(), sample.toString()); +}); + +test('imports apple mail emlx folder into archive', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-emlx-import-')); + const source = path.join(tmp, 'INBOX.mbox', 'Data', 'Messages'); + const archive = path.join(tmp, 'archive'); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, '42.emlx'), Buffer.concat([ + Buffer.from(`${sample.length}\n`, 'utf8'), + sample, + ])); + + const result = await importAppleMailEmlx({ + account: { id: 'vjoati-gmail' }, + sourcePath: path.join(tmp, 'INBOX.mbox'), + archiveRoot: archive, + limit: 10, + }); + + assert.equal(result.imported, 1); + assert.equal(result.records[0].attachments.length, 1); +}); + +test('imports apple mail by account and envelope index', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-apple-index-')); + const mailRoot = path.join(tmp, 'Mail', 'V10'); + const archive = path.join(tmp, 'archive'); + const accountsDb = path.join(tmp, 'Accounts4.sqlite'); + const envelopeIndex = path.join(mailRoot, 'MailData', 'Envelope Index'); + const accountUuid = 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'; + const sourcePath = path.join(mailRoot, accountUuid, '[Gmail].mbox', 'Todos.mbox'); + const rowid = 12345; + const emlxPath = path.join(sourcePath, 'Data', ...bucketPathParts(rowid), 'Messages', `${rowid}.emlx`); + + await fs.mkdir(path.dirname(envelopeIndex), { recursive: true }); + await fs.mkdir(path.dirname(emlxPath), { recursive: true }); + await fs.writeFile(emlxPath, Buffer.concat([ + Buffer.from(`${sample.length}\n`, 'utf8'), + sample, + ])); + + await execSql(accountsDb, ` + create table ZACCOUNT ( + Z_PK integer primary key, + ZPARENTACCOUNT integer, + ZACCOUNTDESCRIPTION varchar, + ZUSERNAME varchar, + ZIDENTIFIER varchar, + ZACCOUNTTYPE integer + ); + create table ZACCOUNTTYPE (Z_PK integer primary key, ZIDENTIFIER varchar); + insert into ZACCOUNTTYPE values (1, 'com.apple.account.Google'); + insert into ZACCOUNTTYPE values (2, 'com.apple.account.IMAP'); + insert into ZACCOUNT values (25, null, 'Google', 'vjoati@gmail.com', 'parent-google', 1); + insert into ZACCOUNT values (26, 25, null, null, '${accountUuid}', 2); + `); + await execSql(envelopeIndex, ` + create table mailboxes ( + ROWID integer primary key, + url text, + total_count integer, + unread_count integer, + unseen_count integer + ); + create table messages ( + ROWID integer primary key, + remote_id integer, + date_received integer, + mailbox integer, + deleted integer + ); + insert into mailboxes values (16, 'imap://${accountUuid}/%5BGmail%5D/Todos', 1, 0, 0); + insert into messages values (${rowid}, 999, 1764547200, 16, 0); + `); + + const result = await importAppleMailFromIndex({ + account: { id: 'vjoati-gmail', email: 'vjoati@gmail.com' }, + mailRoot, + indexPath: envelopeIndex, + accountsDbPath: accountsDb, + archiveRoot: archive, + since: '2025-01-01', + limit: 10, + }); + + assert.equal(result.imported, 1); + assert.equal(result.records[0].appleMail.rowid, rowid); + assert.equal(result.records[0].attachments.length, 1); +}); + +test('prefers populated direct IMAP account over empty child IMAP account', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-apple-direct-index-')); + const mailRoot = path.join(tmp, 'Mail', 'V10'); + const archive = path.join(tmp, 'archive'); + const accountsDb = path.join(tmp, 'Accounts4.sqlite'); + const envelopeIndex = path.join(mailRoot, 'MailData', 'Envelope Index'); + const directUuid = 'DIRECT-IMAP-ACCOUNT'; + const childUuid = 'CHILD-EMPTY-ACCOUNT'; + const rowid = 22345; + const sourcePath = path.join(mailRoot, directUuid, 'INBOX.mbox'); + const emlxPath = path.join(sourcePath, 'Data', ...bucketPathParts(rowid), 'Messages', `${rowid}.emlx`); + + await fs.mkdir(path.dirname(envelopeIndex), { recursive: true }); + await fs.mkdir(path.dirname(emlxPath), { recursive: true }); + await fs.writeFile(emlxPath, Buffer.concat([ + Buffer.from(`${sample.length}\n`, 'utf8'), + sample, + ])); + + await execSql(accountsDb, ` + create table ZACCOUNT ( + Z_PK integer primary key, + ZPARENTACCOUNT integer, + ZACCOUNTDESCRIPTION varchar, + ZUSERNAME varchar, + ZIDENTIFIER varchar, + ZACCOUNTTYPE integer + ); + create table ZACCOUNTTYPE (Z_PK integer primary key, ZIDENTIFIER varchar); + insert into ZACCOUNTTYPE values (1, 'com.apple.account.Google'); + insert into ZACCOUNTTYPE values (2, 'com.apple.account.IMAP'); + insert into ZACCOUNT values (22, null, 'Kdoi Email', 'kdoi@email.com', '${directUuid}', 2); + insert into ZACCOUNT values (39, null, 'kdoi@email.com', 'kdoi@email.com', 'parent-google', 1); + insert into ZACCOUNT values (40, 39, null, null, '${childUuid}', 2); + `); + await execSql(envelopeIndex, ` + create table mailboxes ( + ROWID integer primary key, + url text, + total_count integer, + unread_count integer, + unseen_count integer + ); + create table messages ( + ROWID integer primary key, + remote_id integer, + date_received integer, + mailbox integer, + deleted integer + ); + insert into mailboxes values (10, 'imap://${directUuid}/INBOX', 10, 0, 0); + insert into mailboxes values (48, 'imap://${childUuid}/INBOX', 0, 0, 0); + insert into messages values (${rowid}, 888, 1764547200, 10, 0); + `); + + const result = await importAppleMailFromIndex({ + account: { id: 'kdoi-email', email: 'kdoi@email.com' }, + mailRoot, + indexPath: envelopeIndex, + accountsDbPath: accountsDb, + archiveRoot: archive, + since: '2025-01-01', + limit: 10, + }); + + assert.equal(result.imported, 1); + assert.equal(result.imapAccount.identifier, directUuid); + assert.equal(result.mailbox.url, `imap://${directUuid}/INBOX`); +}); + +test('maps apple mailbox urls and message buckets to filesystem paths', () => { + assert.deepEqual(bucketPathParts(563), []); + assert.deepEqual(bucketPathParts(27431), ['7', '2']); + assert.deepEqual(bucketPathParts(258680), ['8', '5', '2']); + assert.equal( + mailboxPathForUrl('/Mail/V10', 'imap://ACCOUNT/%5BGmail%5D/Todos'), + path.join('/Mail/V10', 'ACCOUNT', '[Gmail].mbox', 'Todos.mbox') + ); +}); + +test('splits and imports mbox messages', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-mbox-')); + const mboxPath = path.join(tmp, 'takeout.mbox'); + const archive = path.join(tmp, 'archive'); + const mbox = Buffer.concat([ + Buffer.from('From sender@example Tue Dec 30 10:30:00 2025\n'), + sample, + Buffer.from('\nFrom sender@example Wed Dec 31 10:30:00 2025\n'), + sample, + ]); + await fs.writeFile(mboxPath, mbox); + + assert.equal(splitMbox(mbox).length, 2); + + const result = await importMbox({ + account: { id: 'vjoati-gmail' }, + mboxPath, + archiveRoot: archive, + limit: 1, + }); + + assert.equal(result.scanned, 2); + assert.equal(result.imported, 1); +}); + +async function execSql(dbPath, sql) { + await execFileAsync('sqlite3', [dbPath, sql]); +} diff --git a/test/mailArchive.test.js b/test/mailArchive.test.js new file mode 100644 index 0000000..4eddbfa --- /dev/null +++ b/test/mailArchive.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { archiveRawEmail } from '../src/mailArchive.js'; + +test('archives raw email and attachments with manifest records', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-money-trace-mail-')); + const source = await fs.readFile('test/fixtures/sample-bank-email.eml'); + const record = await archiveRawEmail({ + account: { id: 'vjoati-gmail' }, + mailbox: 'fixture', + uid: 1, + source, + archiveRoot: tmp, + }); + + assert.equal(record.accountId, 'vjoati-gmail'); + assert.equal(record.mailbox, 'fixture'); + assert.equal(record.subject, 'Estado de cuenta tarjeta credito terminado 5018'); + assert.equal(record.attachments.length, 1); + assert.equal(record.attachments[0].filename, 'estado-cuenta-5018.pdf'); + + await fs.access(record.raw.path); + await fs.access(record.attachments[0].path); + + const manifest = await fs.readFile(path.join(tmp, 'manifests', 'emails.ndjson'), 'utf8'); + assert.match(manifest, /sample-bank-email-5018/); +}); diff --git a/test/moneyGraph.test.js b/test/moneyGraph.test.js new file mode 100644 index 0000000..cada0e1 --- /dev/null +++ b/test/moneyGraph.test.js @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { classifyAllMovements } from '../src/classifier.js'; +import { normalizeLedger } from '../src/domain.js'; +import { buildMoneyGraph, originTree } from '../src/moneyGraph.js'; + +const ledgerFixture = normalizeLedger({ + entities: [ + { id: 'darwin', name: 'Darwin Bruna', kind: 'person' }, + { id: 'muralla', name: 'Muralla SpA', kind: 'company' }, + ], + accounts: [ + { + id: 'darwin-cc', + ownerEntityId: 'darwin', + institution: 'Banco de Chile', + instrument: 'checking_account', + currency: 'CLP', + }, + { + id: 'darwin-visa', + ownerEntityId: 'darwin', + institution: 'Visa', + instrument: 'credit_card', + currency: 'CLP', + }, + ], + movements: [ + { + id: 'income', + date: '2025-12-01', + accountId: 'darwin-cc', + direction: 'in', + amount: { currency: 'CLP', value: 200000 }, + description: 'Ingreso puro', + economicType: 'pure_income', + }, + { + id: 'pay-card', + date: '2026-01-15', + accountId: 'darwin-cc', + cardAccountId: 'darwin-visa', + direction: 'out', + amount: { currency: 'CLP', value: 150000 }, + description: 'PAGO TARJETA VISA', + }, + { + id: 'card-charge', + date: '2025-12-30', + accountId: 'darwin-visa', + direction: 'out', + amount: { currency: 'CLP', value: 126616 }, + description: 'CAFE CULTURA BLACK SPA', + }, + ], + documents: [ + { + id: 'black-spa-10804', + kind: 'dte_invoice', + issuerName: 'BLACK SPA', + receiverEntityId: 'muralla', + amount: { currency: 'CLP', value: 126616 }, + }, + ], + events: [ + { + id: 'black-spa-10804', + kind: 'real_expense', + entityId: 'muralla', + amount: { currency: 'CLP', value: 126616 }, + description: 'Gasto Muralla BLACK SPA', + }, + ], + links: [ + { + from: 'mov:card-charge', + to: 'event:black-spa-10804', + type: 'finances_event', + amount: { currency: 'CLP', value: 126616 }, + method: 'manual', + }, + { + from: 'doc:black-spa-10804', + to: 'event:black-spa-10804', + type: 'documents_event', + amount: { currency: 'CLP', value: 126616 }, + method: 'exact', + }, + ], +}); + +test('builds origin tree from real expense to card, card payment and pure income', () => { + const ledger = classifyAllMovements(ledgerFixture); + const graph = buildMoneyGraph(ledger); + const tree = originTree(graph, 'event:black-spa-10804'); + const json = JSON.stringify(tree); + + assert.match(json, /mov:card-charge/); + assert.match(json, /mov:pay-card/); + assert.match(json, /mov:income/); + assert.match(json, /settles_card_charge/); + assert.match(json, /funds/); +}); + +test('classifies untyped credit-card outflow as card charge', () => { + const ledger = classifyAllMovements(ledgerFixture); + const charge = ledger.movements.find((movement) => movement.id === 'mov:card-charge'); + + assert.equal(charge.economicType, 'card_charge'); +}); + +test('classifies card payment by description', () => { + const ledger = classifyAllMovements(ledgerFixture); + const payment = ledger.movements.find((movement) => movement.id === 'mov:pay-card'); + + assert.equal(payment.economicType, 'card_payment'); +});