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.
@@ -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 = excluded.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