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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +44 -0
- package/src/cli.py +15 -0
- package/src/cli_email.py +492 -0
- package/src/db/_email_accounts.py +170 -0
- package/src/db/_schema.py +50 -0
- package/src/email_config.py +143 -0
- package/src/presets/entities_local.sample.json +30 -0
- package/src/presets/entities_universal.json +4 -81
- package/src/r23b_deploy_vhost.py +2 -2
- package/src/scripts/nexo-email-migrate-config.py +145 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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
|
+
"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",
|
package/src/auto_update.py
CHANGED
|
@@ -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)
|
package/src/cli_email.py
ADDED
|
@@ -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
|
|
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",
|
package/src/r23b_deploy_vhost.py
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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:]))
|