nexo-brain 6.3.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `6.3.0` is the current packaged-runtime line — Plan Consolidado wave 2, coordinated with NEXO Desktop v0.18.0. Closes the remaining Guardian roadmap items that do not require an invasive structure migration: extended `cognitive_sentiment` shape (is_correction/valence/intent), extended `entities` schema, 21 labelled rule fixtures with R13 spike gates, Fase F telemetry loops + Deep Sleep phase, pinned local zero-shot classifier skeleton (mDeBERTa), hook respects `NEXO_MIGRATING=1`, `origin` column on `personal_scripts`, and the T4 LLM gate wrapping R15/R23e/R23f/R23h (byte-parity Py JS). Two pre-release auditors flagged a CRITICAL in the first JS wire (method-name + async mismatch) and a HIGH (classifier bool conflated "no" with "unparseable"); both corrected with regression tests before merge.
21
+ Version `6.4.0` is the current packaged-runtime line — Plan Consolidado fase F1: multi-tenant email accounts (`email_accounts` table, `nexo email setup` interactive wizard, `nexo email add --password-stdin --json` for the Desktop bridge, idempotent migrator from legacy `~/.nexo/nexo-email/config.json`). The matching NEXO Desktop release (v0.19.0) ships the Email + Automations Settings panels so non-technical operators never have to touch a config file. See [CHANGELOG](CHANGELOG.md) for the full diff.
22
+
23
+ Previously in `6.3.1`: privacy hotfix over v6.3.0. The nightly auditor caught that `src/presets/entities_universal.json` in v6.3.0 shipped operator-specific `vhost_mapping` entries (private IPs, hostnames, tenant names). v6.3.1 pulls those out into `src/presets/entities_local.sample.json` (template) + `.gitignore`'d `~/.nexo/brain/presets/entities_local.json` (operator copy), and the installer drops the sample at `nexo init`. No behaviour change on the Guardian side.
24
+
25
+ Previously in `6.3.0` — Plan Consolidado wave 2, coordinated with NEXO Desktop v0.18.0. Closes the remaining Guardian roadmap items that do not require an invasive structure migration: extended `cognitive_sentiment` shape (is_correction/valence/intent), extended `entities` schema, 21 labelled rule fixtures with R13 spike gates, Fase F telemetry loops + Deep Sleep phase, pinned local zero-shot classifier skeleton (mDeBERTa), hook respects `NEXO_MIGRATING=1`, `origin` column on `personal_scripts`, and the T4 LLM gate wrapping R15/R23e/R23f/R23h (byte-parity Py ↔ JS). Two pre-release auditors flagged a CRITICAL in the first JS wire (method-name + async mismatch) and a HIGH (classifier bool conflated "no" with "unparseable"); both corrected with regression tests before merge.
22
26
 
23
27
  Previously in `6.1.1`: small fix to `nexo --help` so the `Latest: vX` line reliably appears when NEXO Desktop invokes the CLI via subprocess — unblocks the Desktop Brain auto-update banner that previously couldn't parse the version delta. No behaviour change for interactive terminal users; the 6-hour registry cache still rate-limits network calls. Bundles all v6.1.0 Protocol Enforcer Fase 2 + multi-claude-sid hotfix content.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -1519,12 +1519,56 @@ def _run_db_migrations() -> bool:
1519
1519
  applied = run_migrations(conn)
1520
1520
  if applied > 0:
1521
1521
  _log(f"Applied {applied} DB migration(s)")
1522
+ # Plan Consolidado F1 — one-shot legacy email config migration.
1523
+ # After m46 adds the table, operators installed pre-v6.4.0 still
1524
+ # keep their data inside ~/.nexo/nexo-email/config.json. If the
1525
+ # table is empty and the JSON exists, port the primary account
1526
+ # over automatically so scripts pick up the new source on the
1527
+ # next cron without the operator running anything manually.
1528
+ _maybe_migrate_legacy_email_config()
1522
1529
  return True
1523
1530
  except Exception as e:
1524
1531
  _log(f"DB migration error: {e}")
1525
1532
  return False
1526
1533
 
1527
1534
 
1535
+ def _maybe_migrate_legacy_email_config() -> None:
1536
+ """F1 auto-migrator — idempotent. Runs the helper script the first
1537
+ time after v6.4.0 lands on an existing runtime."""
1538
+ try:
1539
+ from db._email_accounts import get_email_account
1540
+ except Exception:
1541
+ return # m46 not applied yet (older runtime), nothing to do
1542
+ try:
1543
+ legacy = NEXO_HOME / "nexo-email" / "config.json"
1544
+ if not legacy.exists():
1545
+ return
1546
+ if get_email_account("primary"):
1547
+ return # already migrated
1548
+ import subprocess as _sp
1549
+ script = Path(__file__).resolve().parent / "scripts" / "nexo-email-migrate-config.py"
1550
+ if not script.exists():
1551
+ return
1552
+ _log("F1: migrating legacy email config.json → email_accounts table")
1553
+ env = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parent)}
1554
+ r = _sp.run(
1555
+ [sys.executable, str(script)],
1556
+ env=env,
1557
+ capture_output=True,
1558
+ text=True,
1559
+ timeout=30,
1560
+ )
1561
+ if r.returncode == 0:
1562
+ line = (r.stdout.strip().splitlines() or ["ok"])[-1]
1563
+ _log(f"F1 email migration: {line}")
1564
+ else:
1565
+ _log(f"F1 email migration FAILED (rc={r.returncode}): {r.stderr.strip()[:200]}")
1566
+ except _sp.TimeoutExpired:
1567
+ _log("F1 email migration timed out after 30s")
1568
+ except Exception as exc:
1569
+ _log(f"F1 email migration skipped: {exc}")
1570
+
1571
+
1528
1572
  # ── npm version check (notify only) ─────────────────────────────────
1529
1573
 
1530
1574
  def _check_npm_version() -> str | None:
package/src/cli.py CHANGED
@@ -2046,6 +2046,13 @@ def main():
2046
2046
  parser.add_argument("-v", "--version", action="store_true", help="Show version")
2047
2047
  sub = parser.add_subparsers(dest="command")
2048
2048
 
2049
+ # -- email (Plan F1 — interactive wizard for email accounts) --
2050
+ try:
2051
+ from cli_email import register_email_parser
2052
+ register_email_parser(sub)
2053
+ except Exception as _exc_email: # pragma: no cover
2054
+ pass
2055
+
2049
2056
  # -- chat --
2050
2057
  chat_parser = sub.add_parser("chat", help="Launch a NEXO terminal client")
2051
2058
  chat_parser.add_argument("path", nargs="?", default=".", help="Working directory (default: current directory)")
@@ -2354,6 +2361,14 @@ def main():
2354
2361
  print(f"nexo v{_get_version()}")
2355
2362
  return 0
2356
2363
 
2364
+ if args.command == "email":
2365
+ # Plan F1 — setup / list / test / remove cuentas email.
2366
+ fn = getattr(args, "func", None)
2367
+ if fn is None:
2368
+ print("usage: nexo email {setup,list,test,remove}")
2369
+ return 1
2370
+ return int(fn(args) or 0)
2371
+
2357
2372
  if args.command == "scripts":
2358
2373
  if args.scripts_command == "list":
2359
2374
  return _scripts_list(args)
@@ -0,0 +1,492 @@
1
+ """Plan Consolidado F1 — `nexo email` subcommands.
2
+
3
+ Two consumers, one CLI:
4
+
5
+ 1. Operators on a terminal — friendly interactive wizard with prompts,
6
+ confirmations, and green checks / red errors.
7
+ 2. NEXO Desktop renderer (Plan F1 panel) — every command also accepts
8
+ `--json` for machine-readable I/O and a `--password-stdin` flag to
9
+ accept secrets without leaking them on argv.
10
+
11
+ Subcommands:
12
+ nexo email setup interactive wizard (primary account)
13
+ nexo email add ... non-interactive (Desktop / scripts)
14
+ nexo email list [--json] show all accounts, masked password
15
+ nexo email test <label> [--json] IMAP + SMTP connectivity probe
16
+ nexo email remove <label> [--yes] [--json] remove account + cred
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import getpass
22
+ import json
23
+ import sys
24
+ import time
25
+ from typing import Any
26
+
27
+
28
+ def _prompt(msg: str, default: str = "") -> str:
29
+ suffix = f" [{default}]" if default else ""
30
+ try:
31
+ raw = input(f"{msg}{suffix}: ")
32
+ except (EOFError, KeyboardInterrupt):
33
+ print("\n(cancelled)")
34
+ sys.exit(1)
35
+ return raw.strip() or default
36
+
37
+
38
+ def _prompt_int(msg: str, default: int) -> int:
39
+ while True:
40
+ raw = _prompt(msg, str(default))
41
+ try:
42
+ return int(raw)
43
+ except ValueError:
44
+ print(f" ✗ '{raw}' no es un número. Prueba otra vez.")
45
+
46
+
47
+ def _prompt_yes_no(msg: str, default: bool = True) -> bool:
48
+ d = "Y/n" if default else "y/N"
49
+ while True:
50
+ raw = _prompt(f"{msg} [{d}]").lower()
51
+ if not raw:
52
+ return default
53
+ if raw in ("y", "yes", "s", "si", "sí"):
54
+ return True
55
+ if raw in ("n", "no"):
56
+ return False
57
+ print(" ✗ Responde y o n.")
58
+
59
+
60
+ def _mask_password(pw: str) -> str:
61
+ if not pw:
62
+ return "(vacío)"
63
+ if len(pw) <= 4:
64
+ return "•" * len(pw)
65
+ return pw[0] + "•" * (len(pw) - 2) + pw[-1]
66
+
67
+
68
+ def _store_credential(service: str, key: str, value: str) -> None:
69
+ """Write password to the `credentials` table (simple cleartext by
70
+ default — upgrading to keychain is a v7 follow-up). Never echo the
71
+ password back to stdout."""
72
+ from db._core import get_db
73
+ conn = get_db()
74
+ now = time.time()
75
+ conn.execute(
76
+ """
77
+ INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
78
+ VALUES (?, ?, ?, 'email account password (nexo email setup)', ?, ?)
79
+ ON CONFLICT(service, key) DO UPDATE SET
80
+ value = excluded.value,
81
+ updated_at = excluded.updated_at
82
+ """,
83
+ (service, key, value, now, now),
84
+ )
85
+ conn.commit()
86
+
87
+
88
+ def _delete_credential(service: str, key: str) -> None:
89
+ from db._core import get_db
90
+ conn = get_db()
91
+ conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
92
+ conn.commit()
93
+
94
+
95
+ def cmd_email_setup(args) -> int:
96
+ """Interactive wizard. Fresh install: operator runs this once."""
97
+ print("━" * 60)
98
+ print("NEXO · Asistente de configuración de email")
99
+ print("━" * 60)
100
+ print("Te voy a preguntar los datos de la cuenta de correo que")
101
+ print("NEXO usará para leer y contestar. Si te equivocas, vuelve")
102
+ print("a ejecutar `nexo email setup` en cualquier momento.\n")
103
+
104
+ from db import init_db
105
+ from db._email_accounts import add_email_account, get_email_account
106
+
107
+ init_db()
108
+
109
+ label = _prompt("Etiqueta de la cuenta (ej: 'primary', 'wazion')", "primary")
110
+
111
+ existing = get_email_account(label)
112
+ if existing:
113
+ if not _prompt_yes_no(
114
+ f"Ya existe una cuenta '{label}' ({existing.get('email')}). ¿La sobrescribo?",
115
+ default=False,
116
+ ):
117
+ print("Cancelado.")
118
+ return 1
119
+
120
+ email = _prompt("Dirección email (ej: nexo@tudominio.com)")
121
+ if not email or "@" not in email:
122
+ print(f" ✗ '{email}' no parece un email válido.")
123
+ return 1
124
+
125
+ imap_host = _prompt("Servidor IMAP (entrada)", "imap.gmail.com")
126
+ imap_port = _prompt_int("Puerto IMAP", 993)
127
+ smtp_host = _prompt("Servidor SMTP (salida)", imap_host.replace("imap", "smtp"))
128
+ smtp_port = _prompt_int("Puerto SMTP", 465)
129
+
130
+ try:
131
+ pwd = getpass.getpass("Contraseña (no se mostrará): ")
132
+ except (EOFError, KeyboardInterrupt):
133
+ print("\n(cancelado)")
134
+ return 1
135
+ if not pwd:
136
+ print(" ✗ Necesito una contraseña.")
137
+ return 1
138
+
139
+ operator_email = _prompt(
140
+ "Email donde NEXO te enviará el briefing matinal (tu email personal)",
141
+ email,
142
+ )
143
+
144
+ trusted_raw = _prompt(
145
+ "Dominios de confianza separados por coma (puedes dejar vacío)",
146
+ "",
147
+ )
148
+ trusted = [d.strip() for d in trusted_raw.split(",") if d.strip()]
149
+
150
+ role = _prompt(
151
+ "Rol de la cuenta: inbox (solo leer) / outbox (solo enviar) / both",
152
+ "both",
153
+ )
154
+ if role not in ("inbox", "outbox", "both"):
155
+ role = "both"
156
+
157
+ cred_service = "email"
158
+ cred_key = label
159
+ _store_credential(cred_service, cred_key, pwd)
160
+
161
+ account = add_email_account(
162
+ label=label,
163
+ email=email,
164
+ imap_host=imap_host,
165
+ imap_port=imap_port,
166
+ smtp_host=smtp_host,
167
+ smtp_port=smtp_port,
168
+ credential_service=cred_service,
169
+ credential_key=cred_key,
170
+ operator_email=operator_email,
171
+ trusted_domains=trusted,
172
+ role=role,
173
+ )
174
+
175
+ print()
176
+ print("✓ Cuenta guardada:")
177
+ print(f" label: {account.get('label')}")
178
+ print(f" email: {account.get('email')}")
179
+ print(f" IMAP: {account.get('imap_host')}:{account.get('imap_port')}")
180
+ print(f" SMTP: {account.get('smtp_host')}:{account.get('smtp_port')}")
181
+ print(f" operator_email: {account.get('operator_email')}")
182
+ print(f" trusted: {account.get('trusted_domains') or '(ninguno)'}")
183
+ print(f" role: {account.get('role')}")
184
+ print(f" password: {_mask_password(pwd)} (guardada en credentials)")
185
+ print()
186
+ if _prompt_yes_no("¿Pruebo la conexión ahora?", default=True):
187
+ return cmd_email_test(type("Args", (), {"label": label})())
188
+ print("Puedes probarla cuando quieras con: nexo email test " + label)
189
+ return 0
190
+
191
+
192
+ def _emit_json(payload: dict) -> None:
193
+ """Print a JSON payload on stdout. Used so machine consumers
194
+ (NEXO Desktop renderer) can parse cleanly; the human path keeps
195
+ its rich text output."""
196
+ print(json.dumps(payload, ensure_ascii=False))
197
+
198
+
199
+ def _account_to_public_dict(account: dict) -> dict:
200
+ """Return the operator-safe view of an email account row.
201
+ NEVER includes the password; only flags whether a credential is
202
+ stored so the UI can show a 'no password yet' marker."""
203
+ if not account:
204
+ return {}
205
+ return {
206
+ "label": account.get("label"),
207
+ "email": account.get("email"),
208
+ "imap_host": account.get("imap_host"),
209
+ "imap_port": account.get("imap_port"),
210
+ "smtp_host": account.get("smtp_host"),
211
+ "smtp_port": account.get("smtp_port"),
212
+ "operator_email": account.get("operator_email"),
213
+ "trusted_domains": account.get("trusted_domains") or [],
214
+ "role": account.get("role", "both"),
215
+ "enabled": bool(account.get("enabled", True)),
216
+ "has_credential": bool(account.get("credential_service")
217
+ and account.get("credential_key")),
218
+ }
219
+
220
+
221
+ def cmd_email_list(args) -> int:
222
+ from db import init_db
223
+ from db._email_accounts import list_email_accounts
224
+
225
+ init_db()
226
+ accounts = list_email_accounts(include_disabled=True)
227
+ if getattr(args, "json", False):
228
+ _emit_json({
229
+ "ok": True,
230
+ "accounts": [_account_to_public_dict(a) for a in accounts],
231
+ })
232
+ return 0
233
+ if not accounts:
234
+ print("(sin cuentas configuradas — corre `nexo email setup`)")
235
+ return 0
236
+ print(f"{'LABEL':<16} {'EMAIL':<40} {'ROLE':<8} {'ENABLED':<8} IMAP")
237
+ for a in accounts:
238
+ print(
239
+ f"{a.get('label',''):<16} {a.get('email',''):<40} "
240
+ f"{a.get('role',''):<8} "
241
+ f"{'✓' if a.get('enabled') else '✗':<8} "
242
+ f"{a.get('imap_host','')}:{a.get('imap_port','')}"
243
+ )
244
+ return 0
245
+
246
+
247
+ def cmd_email_add(args) -> int:
248
+ """Non-interactive add. Used by the Desktop email panel and any
249
+ script. Password is read from stdin when ``--password-stdin`` is
250
+ set (so it never appears on argv / ps output)."""
251
+ json_mode = getattr(args, "json", False)
252
+ label = (getattr(args, "label", None) or "").strip()
253
+ email = (getattr(args, "email", None) or "").strip()
254
+ imap_host = (getattr(args, "imap_host", None) or "").strip()
255
+ smtp_host = (getattr(args, "smtp_host", None) or "").strip()
256
+ if not (label and email and imap_host and smtp_host):
257
+ msg = "missing required field (--label, --email, --imap-host, --smtp-host)"
258
+ if json_mode:
259
+ _emit_json({"ok": False, "message": msg})
260
+ else:
261
+ print(f"✗ {msg}")
262
+ return 1
263
+ if "@" not in email:
264
+ msg = f"'{email}' no parece un email válido."
265
+ if json_mode:
266
+ _emit_json({"ok": False, "message": msg})
267
+ else:
268
+ print(f"✗ {msg}")
269
+ return 1
270
+ imap_port = int(getattr(args, "imap_port", None) or 993)
271
+ smtp_port = int(getattr(args, "smtp_port", None) or 465)
272
+ role = (getattr(args, "role", None) or "both").strip()
273
+ if role not in ("inbox", "outbox", "both"):
274
+ role = "both"
275
+ operator_email = (getattr(args, "operator", None) or "").strip()
276
+ trusted_raw = (getattr(args, "trusted_domains", None) or "").strip()
277
+ trusted = [d.strip() for d in trusted_raw.split(",") if d.strip()] if trusted_raw else []
278
+
279
+ if getattr(args, "password_stdin", False):
280
+ try:
281
+ pwd = sys.stdin.read()
282
+ except Exception as exc:
283
+ msg = f"could not read password from stdin: {exc}"
284
+ if json_mode:
285
+ _emit_json({"ok": False, "message": msg})
286
+ else:
287
+ print(f"✗ {msg}")
288
+ return 1
289
+ # Trim a single trailing newline so the operator can pipe with `echo`,
290
+ # but preserve internal whitespace / leading spaces (rare but legal).
291
+ if pwd.endswith("\n"):
292
+ pwd = pwd[:-1]
293
+ if pwd.endswith("\r"):
294
+ pwd = pwd[:-1]
295
+ else:
296
+ pwd = getattr(args, "password", None) or ""
297
+ if not pwd:
298
+ msg = "missing password (use --password-stdin or --password)"
299
+ if json_mode:
300
+ _emit_json({"ok": False, "message": msg})
301
+ else:
302
+ print(f"✗ {msg}")
303
+ return 1
304
+
305
+ from db import init_db
306
+ from db._email_accounts import add_email_account
307
+
308
+ init_db()
309
+ cred_service = "email"
310
+ cred_key = label
311
+ _store_credential(cred_service, cred_key, pwd)
312
+ account = add_email_account(
313
+ label=label,
314
+ email=email,
315
+ imap_host=imap_host,
316
+ imap_port=imap_port,
317
+ smtp_host=smtp_host,
318
+ smtp_port=smtp_port,
319
+ credential_service=cred_service,
320
+ credential_key=cred_key,
321
+ operator_email=operator_email,
322
+ trusted_domains=trusted,
323
+ role=role,
324
+ )
325
+ public = _account_to_public_dict(account)
326
+ if json_mode:
327
+ _emit_json({"ok": True, "account": public})
328
+ else:
329
+ print(f"✓ Cuenta '{label}' guardada.")
330
+ return 0
331
+
332
+
333
+ def cmd_email_test(args) -> int:
334
+ json_mode = getattr(args, "json", False)
335
+ # Keep the legacy positional + the new --label flag both wired.
336
+ label = getattr(args, "label", None) or getattr(args, "label_pos", None)
337
+ if not label:
338
+ msg = "usage: nexo email test <label>"
339
+ if json_mode:
340
+ _emit_json({"ok": False, "message": msg})
341
+ else:
342
+ print(msg)
343
+ return 1
344
+ from db import init_db
345
+ from email_config import load_email_config
346
+
347
+ init_db()
348
+ cfg = load_email_config(label=label)
349
+ if cfg is None:
350
+ msg = f"Cuenta '{label}' no encontrada."
351
+ if json_mode:
352
+ _emit_json({"ok": False, "message": msg})
353
+ else:
354
+ print(f"✗ {msg}")
355
+ return 1
356
+
357
+ ok_imap = False
358
+ err_imap = ""
359
+ ok_smtp = False
360
+ err_smtp = ""
361
+ try:
362
+ import imaplib
363
+ imap = imaplib.IMAP4_SSL(cfg["imap_host"], cfg["imap_port"])
364
+ imap.login(cfg["email"], cfg["password"])
365
+ imap.logout()
366
+ ok_imap = True
367
+ except Exception as exc:
368
+ err_imap = str(exc)
369
+
370
+ try:
371
+ import smtplib
372
+ smtp = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=15)
373
+ smtp.login(cfg["email"], cfg["password"])
374
+ smtp.quit()
375
+ ok_smtp = True
376
+ except Exception as exc:
377
+ err_smtp = str(exc)
378
+
379
+ if json_mode:
380
+ _emit_json({
381
+ "ok": ok_imap and ok_smtp,
382
+ "label": label,
383
+ "imap": {"ok": ok_imap, "host": cfg["imap_host"], "port": cfg["imap_port"], "error": err_imap},
384
+ "smtp": {"ok": ok_smtp, "host": cfg["smtp_host"], "port": cfg["smtp_port"], "error": err_smtp},
385
+ "message": "Login OK" if (ok_imap and ok_smtp) else (err_imap or err_smtp or "test failed"),
386
+ })
387
+ else:
388
+ if ok_imap:
389
+ print(f"✓ IMAP {cfg['imap_host']}:{cfg['imap_port']} login OK")
390
+ else:
391
+ print(f"✗ IMAP {cfg['imap_host']}:{cfg['imap_port']} FAILED: {err_imap}")
392
+ if ok_smtp:
393
+ print(f"✓ SMTP {cfg['smtp_host']}:{cfg['smtp_port']} login OK")
394
+ else:
395
+ print(f"✗ SMTP {cfg['smtp_host']}:{cfg['smtp_port']} FAILED: {err_smtp}")
396
+ return 0 if (ok_imap and ok_smtp) else 1
397
+
398
+
399
+ def cmd_email_remove(args) -> int:
400
+ json_mode = getattr(args, "json", False)
401
+ label = getattr(args, "label", None) or getattr(args, "label_pos", None)
402
+ if not label:
403
+ msg = "usage: nexo email remove <label>"
404
+ if json_mode:
405
+ _emit_json({"ok": False, "message": msg})
406
+ else:
407
+ print(msg)
408
+ return 1
409
+ from db import init_db
410
+ from db._email_accounts import get_email_account, remove_email_account
411
+
412
+ init_db()
413
+ acc = get_email_account(label)
414
+ if not acc:
415
+ msg = f"Cuenta '{label}' no encontrada."
416
+ if json_mode:
417
+ _emit_json({"ok": False, "message": msg})
418
+ else:
419
+ print(f"✗ {msg}")
420
+ return 1
421
+ if not getattr(args, "yes", False):
422
+ if json_mode:
423
+ _emit_json({"ok": False, "message": "missing --yes (interactive confirmation required)"})
424
+ return 1
425
+ if not _prompt_yes_no(f"¿Eliminar la cuenta '{label}' ({acc.get('email')})?", default=False):
426
+ print("Cancelado.")
427
+ return 0
428
+ _delete_credential(acc.get("credential_service", ""), acc.get("credential_key", ""))
429
+ remove_email_account(label)
430
+ if json_mode:
431
+ _emit_json({"ok": True, "label": label, "message": "removed"})
432
+ else:
433
+ print(f"✓ Cuenta '{label}' eliminada.")
434
+ return 0
435
+
436
+
437
+ def register_email_parser(subparsers) -> None:
438
+ """Hook called by cli.py to add the `email` subcommand tree."""
439
+ p = subparsers.add_parser("email", help="Gestionar cuentas de correo NEXO")
440
+ p.set_defaults(func=lambda a: p.print_help() or 0)
441
+ sub = p.add_subparsers(dest="email_action")
442
+
443
+ s = sub.add_parser("setup", help="Asistente interactivo para añadir / reconfigurar una cuenta")
444
+ s.set_defaults(func=cmd_email_setup)
445
+
446
+ s = sub.add_parser("add", help="Añadir cuenta de forma no-interactiva (Desktop / scripts)")
447
+ s.add_argument("--label", required=True)
448
+ s.add_argument("--email", required=True)
449
+ s.add_argument("--imap-host", dest="imap_host", required=True)
450
+ s.add_argument("--imap-port", dest="imap_port", type=int, default=993)
451
+ s.add_argument("--smtp-host", dest="smtp_host", required=True)
452
+ s.add_argument("--smtp-port", dest="smtp_port", type=int, default=465)
453
+ s.add_argument("--operator", dest="operator", default="")
454
+ s.add_argument("--trusted-domains", dest="trusted_domains", default="")
455
+ s.add_argument("--role", dest="role", default="both", choices=["inbox", "outbox", "both"])
456
+ pwd_group = s.add_mutually_exclusive_group()
457
+ pwd_group.add_argument("--password", dest="password",
458
+ help="Password on argv (NOT recommended; visible to ps).")
459
+ pwd_group.add_argument("--password-stdin", dest="password_stdin", action="store_true",
460
+ help="Read password from stdin (recommended).")
461
+ s.add_argument("--json", dest="json", action="store_true")
462
+ s.set_defaults(func=cmd_email_add)
463
+
464
+ s = sub.add_parser("list", help="Listar cuentas configuradas")
465
+ s.add_argument("--json", dest="json", action="store_true")
466
+ s.set_defaults(func=cmd_email_list)
467
+
468
+ s = sub.add_parser("test", help="Probar IMAP + SMTP de una cuenta")
469
+ s.add_argument("label_pos", nargs="?", default=None,
470
+ help="Etiqueta de la cuenta (legacy positional)")
471
+ s.add_argument("--label", dest="label", default=None)
472
+ s.add_argument("--json", dest="json", action="store_true")
473
+ s.set_defaults(func=cmd_email_test)
474
+
475
+ s = sub.add_parser("remove", help="Eliminar una cuenta")
476
+ s.add_argument("label_pos", nargs="?", default=None,
477
+ help="Etiqueta de la cuenta (legacy positional)")
478
+ s.add_argument("--label", dest="label", default=None)
479
+ s.add_argument("--yes", dest="yes", action="store_true",
480
+ help="Skip the interactive confirmation (required for --json).")
481
+ s.add_argument("--json", dest="json", action="store_true")
482
+ s.set_defaults(func=cmd_email_remove)
483
+
484
+
485
+ __all__ = [
486
+ "cmd_email_setup",
487
+ "cmd_email_add",
488
+ "cmd_email_list",
489
+ "cmd_email_test",
490
+ "cmd_email_remove",
491
+ "register_email_parser",
492
+ ]
@@ -0,0 +1,170 @@
1
+ """Plan Consolidado F1 — email_accounts CRUD.
2
+
3
+ First-class multi-account email config. Replaces the legacy flat JSON
4
+ at ~/.nexo/nexo-email/config.json (single tenant, password cleartext,
5
+ operator-specific fields) with a structured table. Credentials never
6
+ land in this row — they live in the `credentials` table referenced by
7
+ `credential_service` + `credential_key`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from typing import Any
15
+
16
+ from db._core import get_db
17
+
18
+
19
+ DEFAULT_IMAP_PORT = 993
20
+ DEFAULT_SMTP_PORT = 465
21
+ VALID_ROLES = ("inbox", "outbox", "both")
22
+
23
+
24
+ def _row_to_dict(row) -> dict:
25
+ if row is None:
26
+ return {}
27
+ d = dict(row)
28
+ try:
29
+ d["trusted_domains"] = json.loads(d.get("trusted_domains") or "[]") or []
30
+ except Exception:
31
+ d["trusted_domains"] = []
32
+ try:
33
+ d["metadata"] = json.loads(d.get("metadata") or "{}") or {}
34
+ except Exception:
35
+ d["metadata"] = {}
36
+ d["enabled"] = bool(d.get("enabled", 1))
37
+ return d
38
+
39
+
40
+ def add_email_account(
41
+ *,
42
+ label: str,
43
+ email: str,
44
+ imap_host: str = "",
45
+ imap_port: int = DEFAULT_IMAP_PORT,
46
+ smtp_host: str = "",
47
+ smtp_port: int = DEFAULT_SMTP_PORT,
48
+ credential_service: str = "",
49
+ credential_key: str = "",
50
+ operator_email: str = "",
51
+ trusted_domains: list[str] | None = None,
52
+ role: str = "both",
53
+ enabled: bool = True,
54
+ metadata: dict | None = None,
55
+ ) -> dict:
56
+ if not label or not email:
57
+ raise ValueError("label and email are required")
58
+ if role not in VALID_ROLES:
59
+ raise ValueError(f"role must be one of {VALID_ROLES}, got {role!r}")
60
+ conn = get_db()
61
+ now = time.strftime("%Y-%m-%d %H:%M:%S")
62
+ # Audit H2: when the caller does not pass `metadata` explicitly,
63
+ # an upsert would otherwise wipe whatever the operator (or another
64
+ # subsystem like auto_capture / poll-tuning) had previously stored
65
+ # on this label. Preserve the existing metadata in that case so
66
+ # the only way to clear it is `metadata={}` explicit.
67
+ if metadata is None:
68
+ existing = get_email_account(label) or {}
69
+ metadata = existing.get("metadata") if existing.get("metadata") else {}
70
+ conn.execute(
71
+ """
72
+ INSERT INTO email_accounts (
73
+ label, email, imap_host, imap_port, smtp_host, smtp_port,
74
+ credential_service, credential_key, operator_email,
75
+ trusted_domains, role, enabled, metadata, created_at, updated_at
76
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
+ ON CONFLICT(label) DO UPDATE SET
78
+ email = excluded.email,
79
+ imap_host = excluded.imap_host,
80
+ imap_port = excluded.imap_port,
81
+ smtp_host = excluded.smtp_host,
82
+ smtp_port = excluded.smtp_port,
83
+ credential_service = excluded.credential_service,
84
+ credential_key = excluded.credential_key,
85
+ operator_email = excluded.operator_email,
86
+ trusted_domains = excluded.trusted_domains,
87
+ role = excluded.role,
88
+ enabled = excluded.enabled,
89
+ metadata = excluded.metadata,
90
+ updated_at = excluded.updated_at
91
+ """,
92
+ (
93
+ label,
94
+ email,
95
+ imap_host,
96
+ int(imap_port),
97
+ smtp_host,
98
+ int(smtp_port),
99
+ credential_service,
100
+ credential_key,
101
+ operator_email,
102
+ json.dumps(trusted_domains or [], ensure_ascii=False),
103
+ role,
104
+ 1 if enabled else 0,
105
+ json.dumps(metadata or {}, ensure_ascii=False),
106
+ now,
107
+ now,
108
+ ),
109
+ )
110
+ conn.commit()
111
+ return get_email_account(label) or {}
112
+
113
+
114
+ def list_email_accounts(include_disabled: bool = True) -> list[dict]:
115
+ conn = get_db()
116
+ where = "" if include_disabled else "WHERE enabled = 1"
117
+ rows = conn.execute(
118
+ f"SELECT * FROM email_accounts {where} ORDER BY label COLLATE NOCASE"
119
+ ).fetchall()
120
+ return [_row_to_dict(r) for r in rows]
121
+
122
+
123
+ def get_email_account(label: str) -> dict | None:
124
+ conn = get_db()
125
+ row = conn.execute(
126
+ "SELECT * FROM email_accounts WHERE label = ?",
127
+ (label,),
128
+ ).fetchone()
129
+ return _row_to_dict(row) if row else None
130
+
131
+
132
+ def get_primary_email_account() -> dict | None:
133
+ """Most-recently-updated enabled account. Returns None if table empty."""
134
+ conn = get_db()
135
+ row = conn.execute(
136
+ "SELECT * FROM email_accounts WHERE enabled = 1 "
137
+ "ORDER BY updated_at DESC LIMIT 1"
138
+ ).fetchone()
139
+ return _row_to_dict(row) if row else None
140
+
141
+
142
+ def set_email_account_enabled(label: str, enabled: bool) -> bool:
143
+ conn = get_db()
144
+ cur = conn.execute(
145
+ "UPDATE email_accounts SET enabled = ?, updated_at = datetime('now') "
146
+ "WHERE label = ?",
147
+ (1 if enabled else 0, label),
148
+ )
149
+ conn.commit()
150
+ return cur.rowcount > 0
151
+
152
+
153
+ def remove_email_account(label: str) -> bool:
154
+ conn = get_db()
155
+ cur = conn.execute("DELETE FROM email_accounts WHERE label = ?", (label,))
156
+ conn.commit()
157
+ return cur.rowcount > 0
158
+
159
+
160
+ __all__ = [
161
+ "add_email_account",
162
+ "list_email_accounts",
163
+ "get_email_account",
164
+ "get_primary_email_account",
165
+ "set_email_account_enabled",
166
+ "remove_email_account",
167
+ "DEFAULT_IMAP_PORT",
168
+ "DEFAULT_SMTP_PORT",
169
+ "VALID_ROLES",
170
+ ]
package/src/db/_schema.py CHANGED
@@ -1083,6 +1083,55 @@ def _m43_session_claude_aliases(conn):
1083
1083
  )
1084
1084
 
1085
1085
 
1086
+ def _m46_email_accounts(conn):
1087
+ """Plan Consolidado F1 — first-class multi-account email config.
1088
+
1089
+ Replaces the legacy ~/.nexo/nexo-email/config.json (single tenant,
1090
+ password in cleartext, Francisco-hardcoded) with a structured table.
1091
+
1092
+ Columns:
1093
+ - id: internal primary key.
1094
+ - label: operator-friendly name ('primary', 'wazion', 'canari').
1095
+ - email: address the account sends from.
1096
+ - imap_host, imap_port: inbound server.
1097
+ - smtp_host, smtp_port: outbound server.
1098
+ - credential_service, credential_key: reference into the
1099
+ `credentials` table (never store the password in this row).
1100
+ - operator_email: where the briefing / digest is sent when this
1101
+ account runs the morning agent.
1102
+ - trusted_domains: JSON array of domains the inbox treats as
1103
+ priority (not hard filter).
1104
+ - role: 'inbox' (monitor only), 'outbox' (send only), 'both'.
1105
+ - enabled: on/off without having to delete the row.
1106
+ - created_at / updated_at.
1107
+
1108
+ Idempotent.
1109
+ """
1110
+ conn.execute(
1111
+ """
1112
+ CREATE TABLE IF NOT EXISTS email_accounts (
1113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1114
+ label TEXT NOT NULL UNIQUE,
1115
+ email TEXT NOT NULL,
1116
+ imap_host TEXT NOT NULL DEFAULT '',
1117
+ imap_port INTEGER NOT NULL DEFAULT 993,
1118
+ smtp_host TEXT NOT NULL DEFAULT '',
1119
+ smtp_port INTEGER NOT NULL DEFAULT 465,
1120
+ credential_service TEXT NOT NULL DEFAULT '',
1121
+ credential_key TEXT NOT NULL DEFAULT '',
1122
+ operator_email TEXT NOT NULL DEFAULT '',
1123
+ trusted_domains TEXT NOT NULL DEFAULT '[]',
1124
+ role TEXT NOT NULL DEFAULT 'both',
1125
+ enabled INTEGER NOT NULL DEFAULT 1,
1126
+ metadata TEXT NOT NULL DEFAULT '{}',
1127
+ created_at TEXT DEFAULT (datetime('now')),
1128
+ updated_at TEXT DEFAULT (datetime('now'))
1129
+ )
1130
+ """
1131
+ )
1132
+ _migrate_add_index(conn, "idx_email_accounts_enabled", "email_accounts", "enabled")
1133
+
1134
+
1086
1135
  def _m45_personal_scripts_origin(conn):
1087
1136
  """Plan Consolidado F0.1 — mark whether a personal_scripts row is
1088
1137
  installed by NEXO Core (origin='core'), contributed by the operator
@@ -1164,6 +1213,7 @@ MIGRATIONS = [
1164
1213
  (43, "session_claude_aliases", _m43_session_claude_aliases),
1165
1214
  (44, "entities_extended_schema", _m44_entities_extended_schema),
1166
1215
  (45, "personal_scripts_origin", _m45_personal_scripts_origin),
1216
+ (46, "email_accounts", _m46_email_accounts),
1167
1217
  ]
1168
1218
 
1169
1219
 
@@ -0,0 +1,143 @@
1
+ """Plan Consolidado F1 — single loader for scripts that used to read
2
+ ~/.nexo/nexo-email/config.json directly.
3
+
4
+ The loader prefers the `email_accounts` table. When the table is empty
5
+ (fresh install that hasn't run `nexo email setup` yet) it falls back
6
+ to the legacy JSON for backwards compatibility — no crons stall while
7
+ Francisco migrates.
8
+
9
+ Usage from any script:
10
+
11
+ from email_config import load_email_config
12
+ cfg = load_email_config() # returns dict with the shape the legacy
13
+ # config.json used to have
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ _logger = logging.getLogger(__name__)
25
+
26
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME") or (Path.home() / ".nexo"))
27
+ LEGACY_CONFIG_PATH = NEXO_HOME / "nexo-email" / "config.json"
28
+
29
+
30
+ def _get_credential(service: str, key: str) -> str:
31
+ """Fetch a password from the credentials table. Returns empty string
32
+ on any miss so the caller can log-and-skip instead of crashing a cron.
33
+ """
34
+ if not service or not key:
35
+ return ""
36
+ try:
37
+ from db._core import get_db
38
+ except Exception: # pragma: no cover
39
+ return ""
40
+ try:
41
+ conn = get_db()
42
+ row = conn.execute(
43
+ "SELECT value FROM credentials WHERE service = ? AND key = ?",
44
+ (service, key),
45
+ ).fetchone()
46
+ if row is None:
47
+ return ""
48
+ return str(row[0] or "")
49
+ except Exception as exc: # pragma: no cover
50
+ _logger.warning("credential lookup failed for %s/%s: %s", service, key, exc)
51
+ return ""
52
+
53
+
54
+ def _account_to_legacy_shape(account: dict, extra_operator_emails: list[str]) -> dict:
55
+ """Project an email_accounts row onto the dict the legacy code expects."""
56
+ password = _get_credential(
57
+ account.get("credential_service", ""),
58
+ account.get("credential_key", ""),
59
+ )
60
+ return {
61
+ "imap_host": account.get("imap_host", ""),
62
+ "imap_port": int(account.get("imap_port") or 993),
63
+ "smtp_host": account.get("smtp_host", ""),
64
+ "smtp_port": int(account.get("smtp_port") or 465),
65
+ "email": account.get("email", ""),
66
+ "password": password,
67
+ "operator_email": account.get("operator_email", ""),
68
+ "francisco_emails": list(extra_operator_emails or []),
69
+ "trusted_domains": list(account.get("trusted_domains") or []),
70
+ "sender_policy": account.get("metadata", {}).get("sender_policy", "open"),
71
+ "check_interval_seconds": account.get("metadata", {}).get("check_interval_seconds", 60),
72
+ "max_retries": account.get("metadata", {}).get("max_retries", 3),
73
+ "retry_backoff_seconds": account.get("metadata", {}).get("retry_backoff_seconds", 60),
74
+ "claude_binary": account.get("metadata", {}).get("claude_binary", ""),
75
+ "working_dir": account.get("metadata", {}).get("working_dir", str(Path.home())),
76
+ "automation_task_profile": account.get("metadata", {}).get("automation_task_profile", "deep"),
77
+ "max_process_time": account.get("metadata", {}).get("max_process_time"),
78
+ "label": account.get("label", ""),
79
+ "role": account.get("role", "both"),
80
+ "_source": "email_accounts",
81
+ }
82
+
83
+
84
+ def _load_legacy_json() -> dict | None:
85
+ """Read ~/.nexo/nexo-email/config.json if it exists."""
86
+ if not LEGACY_CONFIG_PATH.exists():
87
+ return None
88
+ try:
89
+ data = json.loads(LEGACY_CONFIG_PATH.read_text())
90
+ except Exception as exc:
91
+ _logger.warning("legacy email config unparseable: %s", exc)
92
+ return None
93
+ if not isinstance(data, dict):
94
+ return None
95
+ data["_source"] = "legacy-config-json"
96
+ return data
97
+
98
+
99
+ def load_email_config(label: str | None = None) -> dict | None:
100
+ """Return the email config for a given label (or the primary account).
101
+
102
+ Preference order:
103
+ 1. email_accounts table (via label or get_primary_email_account).
104
+ 2. ~/.nexo/nexo-email/config.json legacy file.
105
+ 3. None if neither is available.
106
+ """
107
+ account: dict | None = None
108
+ try:
109
+ from db._email_accounts import get_email_account, get_primary_email_account
110
+ if label:
111
+ account = get_email_account(label)
112
+ else:
113
+ account = get_primary_email_account()
114
+ except Exception as exc:
115
+ _logger.warning("email_accounts lookup failed: %s", exc)
116
+
117
+ if account:
118
+ extra: list[str] = []
119
+ try:
120
+ from db._core import get_db
121
+ conn = get_db()
122
+ rows = conn.execute(
123
+ "SELECT email FROM email_accounts WHERE role IN ('inbox','both') AND enabled = 1"
124
+ ).fetchall()
125
+ extra = [r[0] for r in rows if r[0]]
126
+ except Exception:
127
+ pass
128
+ # F1 — also surface metadata.operator_aliases (the legacy
129
+ # `francisco_emails` list) so personal aliases keep treated as
130
+ # "operator's own messages".
131
+ aliases = (account.get("metadata") or {}).get("operator_aliases") or []
132
+ for a in aliases:
133
+ if a and a not in extra:
134
+ extra.append(a)
135
+ return _account_to_legacy_shape(account, extra)
136
+
137
+ return _load_legacy_json()
138
+
139
+
140
+ __all__ = [
141
+ "load_email_config",
142
+ "LEGACY_CONFIG_PATH",
143
+ ]
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://nexo-brain.com/schemas/entities-preset-v1.json",
3
+ "version": "1.0.0",
4
+ "description": "Operator-specific entity overrides. Copy to ~/.nexo/brain/presets/entities_local.json and edit with YOUR domains, hosts, IPs, tenants. This file is .gitignored — never committed to the public package. The installer merges local entries on top of entities_universal.json at `nexo init` and during `nexo update`.",
5
+ "source": "local",
6
+ "confidence": 1.0,
7
+ "entities": [
8
+ {
9
+ "type": "vhost_mapping",
10
+ "name": "example_main_site",
11
+ "metadata": {
12
+ "domain": "example.com",
13
+ "host": "ssh-alias-for-your-host",
14
+ "docroot": "/home/user/public_html",
15
+ "note": "Replace with your real production site. Host must match ~/.ssh/config alias."
16
+ }
17
+ },
18
+ {
19
+ "type": "host",
20
+ "name": "example-host-alias",
21
+ "aliases": ["example-ssh-nickname"],
22
+ "metadata": {
23
+ "ip": "203.0.113.1",
24
+ "provider": "your-cloud-provider",
25
+ "access_mode": "read_write",
26
+ "note": "Replace with your real hosts. NEXO reads ~/.ssh/config automatically so you usually don't need to list hosts here."
27
+ }
28
+ }
29
+ ]
30
+ }
@@ -154,7 +154,6 @@
154
154
  "type": "artifact_class",
155
155
  "name": "email_to_operator_contact",
156
156
  "aliases": [
157
- "email_to_maria",
158
157
  "operator_email",
159
158
  "client_message"
160
159
  ],
@@ -162,23 +161,10 @@
162
161
  "tool": "nexo_email_send",
163
162
  "required_from": "info@<operator-domain>",
164
163
  "when": "reply / outreach to an operator's external contact (client, vendor, counterparty)",
165
- "anti_example": "sending from nexo@systeam.es on behalf of Maria's CanaRirural shop",
164
+ "anti_example": "sending from the NEXO default mailbox on behalf of an operator's external tenant",
166
165
  "reference_learning": "feedback_no_email_followup_completados"
167
166
  }
168
167
  },
169
- {
170
- "type": "artifact_class",
171
- "name": "shopify_banner_block",
172
- "aliases": [
173
- "banner",
174
- "promo_strip"
175
- ],
176
- "metadata": {
177
- "platform": "shopify",
178
- "constraints": "max-width container with visible borders — NEVER a hero full-width bleed",
179
- "reference_learning": "feedback_shopify_banner_not_hero"
180
- }
181
- },
182
168
  {
183
169
  "type": "artifact_class",
184
170
  "name": "changelog_entry",
@@ -191,81 +177,18 @@
191
177
  "reference_script": "scripts/verify_release_readiness.py"
192
178
  }
193
179
  },
194
- {
195
- "type": "vhost_mapping",
196
- "name": "systeam_es",
197
- "metadata": {
198
- "domain": "systeam.es",
199
- "host": "vicshop",
200
- "docroot": "/home/vicshopsysteam/public_html",
201
- "note": "Francisco advisory web"
202
- }
203
- },
204
- {
205
- "type": "vhost_mapping",
206
- "name": "wazion_com",
207
- "metadata": {
208
- "domain": "wazion.com",
209
- "host": "wazion-gcp",
210
- "docroot": "/var/www/wazion.com/public_html"
211
- }
212
- },
213
- {
214
- "type": "vhost_mapping",
215
- "name": "recambios_bmw",
216
- "metadata": {
217
- "domain": "recambios-bmw.es",
218
- "host": "mundiserver",
219
- "docroot": "/home/vicshop/public_html"
220
- }
221
- },
222
- {
223
- "type": "vhost_mapping",
224
- "name": "allinoneapp",
225
- "metadata": {
226
- "domain": "allinoneapp.com",
227
- "host": "mundiserver",
228
- "docroot": "/home/vicshop/allinoneapp"
229
- }
230
- },
231
- {
232
- "type": "vhost_mapping",
233
- "name": "bulksend",
234
- "metadata": {
235
- "domain": "bulksend.app",
236
- "host": "mundiserver",
237
- "docroot": "/home/vicshop/bulksend"
238
- }
239
- },
240
180
  {
241
181
  "type": "vhost_mapping",
242
182
  "name": "nexo_brain",
243
183
  "metadata": {
244
184
  "domain": "nexo-brain.com",
245
185
  "host": "github-pages",
246
- "docroot": "public/"
247
- }
248
- },
249
- {
250
- "type": "vhost_mapping",
251
- "name": "canarirural",
252
- "metadata": {
253
- "domain": "canarirural.com",
254
- "host": "mundiserver",
255
- "docroot": "/home/canariru/public_html",
256
- "note": "Shop ID 14 servidor 45.148.1.111, Maria tenant"
257
- }
258
- },
259
- {
260
- "type": "vhost_mapping",
261
- "name": "vic_shop",
262
- "metadata": {
263
- "domain": "vicshop.com",
264
- "host": "vicshop",
265
- "docroot": "/home/vicshopsysteam/vicshop"
186
+ "docroot": "public/",
187
+ "note": "Public product site. Safe to ship in the universal preset."
266
188
  }
267
189
  }
268
190
  ],
191
+ "local_overrides_note": "Operator-specific vhost_mapping entries (private domains, IPs, hostnames, tenant names) live in ~/.nexo/brain/presets/entities_local.json — copy from src/presets/entities_local.sample.json. The installer merges local entries on top of this universal preset at `nexo init`. Never commit operator data to entities_universal.json — v6.3.0 accidentally shipped seven such entries and was patched in v6.3.1.",
269
192
  "ssh_config_import": {
270
193
  "enabled_default": true,
271
194
  "source": "~/.ssh/config",
@@ -4,8 +4,8 @@ Pure decision module. Part of Fase D2 (hard bloqueante).
4
4
 
5
5
  Triggers when an scp/rsync command writes to a remote path that maps to
6
6
  a known vhost_mapping entity, and the surrounding context references a
7
- different domain. The classic incident: pushing systeam.es assets to
8
- the vicshop docroot because the mental-model-cached entity was wrong.
7
+ different domain. The classic incident: pushing site-A assets to
8
+ site-B's docroot because the mental-model-cached entity was wrong.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ # nexo: name=nexo-email-migrate-config
3
+ # nexo: description=One-shot migrator: copy ~/.nexo/nexo-email/config.json into the email_accounts table (F1).
4
+ # nexo: category=automation
5
+ # nexo: runtime=python
6
+ # nexo: timeout=30
7
+ # nexo: idempotent=true
8
+
9
+ """Plan Consolidado F1 — one-shot migration.
10
+
11
+ Reads ~/.nexo/nexo-email/config.json (v6.3.x legacy single-tenant file)
12
+ and inserts it into the `email_accounts` table under label 'primary'.
13
+ Password lands in the `credentials` table under service='email'/key='primary'.
14
+
15
+ Idempotent: if a 'primary' row already exists, we skip (no overwrite).
16
+ Callers can re-run with --force to overwrite.
17
+
18
+ Runs automatically from auto_update.py the first time the operator
19
+ updates to v6.4.0 or later. Can also be invoked manually.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+
31
+
32
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME") or (Path.home() / ".nexo"))
33
+ CODE_ROOT = Path(os.environ.get("NEXO_CODE") or (Path(__file__).resolve().parents[1]))
34
+ if str(CODE_ROOT) not in sys.path:
35
+ sys.path.insert(0, str(CODE_ROOT))
36
+
37
+ LEGACY_PATH = NEXO_HOME / "nexo-email" / "config.json"
38
+
39
+
40
+ def _load_legacy() -> dict | None:
41
+ if not LEGACY_PATH.exists():
42
+ return None
43
+ try:
44
+ return json.loads(LEGACY_PATH.read_text())
45
+ except Exception as exc:
46
+ print(f"✗ legacy config unparseable: {exc}", file=sys.stderr)
47
+ return None
48
+
49
+
50
+ def main(argv: list[str]) -> int:
51
+ parser = argparse.ArgumentParser(description=__doc__)
52
+ parser.add_argument("--force", action="store_true")
53
+ parser.add_argument("--label", default="primary")
54
+ parser.add_argument("--dry-run", action="store_true")
55
+ args = parser.parse_args(argv)
56
+
57
+ legacy = _load_legacy()
58
+ if legacy is None:
59
+ print(f"(nada que migrar — {LEGACY_PATH} no existe)")
60
+ return 0
61
+
62
+ from db import init_db
63
+ from db._email_accounts import add_email_account, get_email_account
64
+
65
+ init_db()
66
+
67
+ existing = get_email_account(args.label)
68
+ if existing and not args.force:
69
+ print(
70
+ f"(cuenta '{args.label}' ya existe — email={existing.get('email')}, "
71
+ f"sin cambios). Usa --force para sobrescribir."
72
+ )
73
+ return 0
74
+
75
+ email = legacy.get("email", "")
76
+ password = legacy.get("password", "")
77
+ imap_host = legacy.get("imap_host", "")
78
+ imap_port = int(legacy.get("imap_port") or 993)
79
+ smtp_host = legacy.get("smtp_host", imap_host)
80
+ smtp_port = int(legacy.get("smtp_port") or 465)
81
+ operator_email = legacy.get("operator_email", "")
82
+ trusted = legacy.get("trusted_domains", []) or []
83
+ francisco = legacy.get("francisco_emails", []) or []
84
+
85
+ metadata = {
86
+ "sender_policy": legacy.get("sender_policy", "open"),
87
+ "check_interval_seconds": legacy.get("check_interval_seconds", 60),
88
+ "max_retries": legacy.get("max_retries", 3),
89
+ "retry_backoff_seconds": legacy.get("retry_backoff_seconds", 60),
90
+ "claude_binary": legacy.get("claude_binary", ""),
91
+ "working_dir": legacy.get("working_dir", str(Path.home())),
92
+ "automation_task_profile": legacy.get("automation_task_profile", "deep"),
93
+ "max_process_time": legacy.get("max_process_time"),
94
+ "operator_aliases": francisco,
95
+ }
96
+
97
+ cred_service = "email"
98
+ cred_key = args.label
99
+
100
+ if args.dry_run:
101
+ print(f"[dry-run] add_email_account(label={args.label}, email={email}, "
102
+ f"imap={imap_host}:{imap_port}, smtp={smtp_host}:{smtp_port}, "
103
+ f"role=both, operator={operator_email}, trusted={trusted})")
104
+ print(f"[dry-run] credentials[{cred_service}/{cred_key}] = <password>")
105
+ return 0
106
+
107
+ # Store credential
108
+ from db._core import get_db
109
+ conn = get_db()
110
+ now = time.time()
111
+ conn.execute(
112
+ """
113
+ INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
114
+ VALUES (?, ?, ?, 'migrated from ~/.nexo/nexo-email/config.json (F1)', ?, ?)
115
+ ON CONFLICT(service, key) DO UPDATE SET
116
+ value = excluded.value, updated_at = excluded.updated_at
117
+ """,
118
+ (cred_service, cred_key, password, now, now),
119
+ )
120
+ conn.commit()
121
+
122
+ account = add_email_account(
123
+ label=args.label,
124
+ email=email,
125
+ imap_host=imap_host,
126
+ imap_port=imap_port,
127
+ smtp_host=smtp_host,
128
+ smtp_port=smtp_port,
129
+ credential_service=cred_service,
130
+ credential_key=cred_key,
131
+ operator_email=operator_email,
132
+ trusted_domains=trusted,
133
+ role="both",
134
+ metadata=metadata,
135
+ )
136
+
137
+ print(f"✓ Cuenta '{args.label}' migrada ({account.get('email')}).")
138
+ print(f" Password guardada en credentials[{cred_service}/{cred_key}].")
139
+ print(f" Metadata: operator_aliases={len(francisco)}, trusted_domains={len(trusted)}.")
140
+ print(f" Legacy JSON intacto en {LEGACY_PATH} (borrarlo tras verificar con `nexo email test {args.label}`).")
141
+ return 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ sys.exit(main(sys.argv[1:]))