nexo-brain 6.3.0 → 6.5.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 +10 -1
- package/package.json +1 -1
- package/src/auto_update.py +44 -0
- package/src/cli.py +77 -0
- package/src/cli_email.py +492 -0
- package/src/db/_email_accounts.py +170 -0
- package/src/db/_personal_scripts.py +3 -1
- 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/script_registry.py +104 -0
- package/src/scripts/nexo-cron-wrapper.sh +38 -0
- package/src/scripts/nexo-email-migrate-config.py +145 -0
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
|
+
]
|
|
@@ -122,7 +122,9 @@ def upsert_personal_script(
|
|
|
122
122
|
metadata_json = excluded.metadata_json,
|
|
123
123
|
created_by = COALESCE(NULLIF(personal_scripts.created_by, ''), excluded.created_by),
|
|
124
124
|
source = excluded.source,
|
|
125
|
-
enabled
|
|
125
|
+
-- Plan F0.2.2: preserve operator-set `enabled` flag across sync runs.
|
|
126
|
+
-- Sync defaults to enabled=True for INSERTs; on UPDATE we keep
|
|
127
|
+
-- whatever the operator (or `nexo scripts disable`) set.
|
|
126
128
|
has_inline_metadata = excluded.has_inline_metadata,
|
|
127
129
|
last_synced_at = excluded.last_synced_at,
|
|
128
130
|
updated_at = excluded.updated_at
|