@agenticmail/api 0.9.25 → 0.9.27

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/dist/index.js CHANGED
@@ -282,6 +282,30 @@ function closeAllSystemEventListeners() {
282
282
  }
283
283
  function createSystemEventRoutes() {
284
284
  const router = Router2();
285
+ router.get("/system/operator-email", requireMaster, async (_req, res) => {
286
+ try {
287
+ const { getOperatorEmail } = await import("@agenticmail/core");
288
+ res.json({ email: getOperatorEmail() });
289
+ } catch (err) {
290
+ res.status(500).json({ error: err.message });
291
+ }
292
+ });
293
+ router.patch("/system/operator-email", requireMaster, async (req, res) => {
294
+ try {
295
+ const { setOperatorEmail } = await import("@agenticmail/core");
296
+ const raw = req.body && typeof req.body === "object" ? req.body.email : null;
297
+ const email = typeof raw === "string" ? raw : null;
298
+ const stored = setOperatorEmail(email);
299
+ res.json({ email: stored });
300
+ } catch (err) {
301
+ const msg = err.message;
302
+ if (msg.includes("must contain an @")) {
303
+ res.status(400).json({ error: msg });
304
+ return;
305
+ }
306
+ res.status(500).json({ error: msg });
307
+ }
308
+ });
285
309
  router.get("/system/events", requireMaster, (req, res) => {
286
310
  res.setHeader("Content-Type", "text/event-stream");
287
311
  res.setHeader("Cache-Control", "no-cache");
@@ -5326,7 +5350,7 @@ var skipped = [];
5326
5350
  var SKIPPED_CAP = 100;
5327
5351
  var SKIPPED_TTL_MS = 5 * 60 * 1e3;
5328
5352
  var processState = null;
5329
- function createDispatcherActivityRoutes() {
5353
+ function createDispatcherActivityRoutes(deps = {}) {
5330
5354
  const router = Router13();
5331
5355
  router.post("/dispatcher/worker-started", requireMaster, (req, res) => {
5332
5356
  const body = req.body ?? {};
@@ -5490,7 +5514,7 @@ function createDispatcherActivityRoutes() {
5490
5514
  });
5491
5515
  res.json({ ok: true });
5492
5516
  });
5493
- router.post("/dispatcher/bridge-escalation", requireMaster, (req, res) => {
5517
+ router.post("/dispatcher/bridge-escalation", requireMaster, async (req, res) => {
5494
5518
  const body = req.body ?? {};
5495
5519
  const event = {
5496
5520
  type: "bridge_escalation",
@@ -5505,7 +5529,43 @@ function createDispatcherActivityRoutes() {
5505
5529
  atMs: Date.now()
5506
5530
  };
5507
5531
  pushSystemEvent(event);
5508
- res.json({ ok: true, escalated: true });
5532
+ let forwarded = false;
5533
+ try {
5534
+ const { getOperatorEmail } = await import("@agenticmail/core");
5535
+ const operatorEmail = getOperatorEmail();
5536
+ if (operatorEmail && deps.gatewayManager && deps.accountManager && event.agentName) {
5537
+ const bridge = await deps.accountManager.getByName(event.agentName);
5538
+ if (bridge) {
5539
+ const subjectLine = `[AgenticMail Alert] Sub-agent needs your attention \u2014 ${event.subject ?? "(no subject)"}`;
5540
+ const lines = [
5541
+ `A sub-agent mailed your ${event.agentName}@localhost bridge inbox and the dispatcher could not resume a host session to handle it on your behalf.`,
5542
+ "",
5543
+ `Reason: ${event.reason}${event.errorMessage ? ` \u2014 ${event.errorMessage.slice(0, 160)}` : ""}`,
5544
+ "",
5545
+ `From: ${event.from ?? "unknown"}`,
5546
+ `Subject: ${event.subject ?? "(no subject)"}`,
5547
+ `UID: ${event.uid ?? "?"}`,
5548
+ "",
5549
+ event.preview ? `Preview:
5550
+ ${event.preview.slice(0, 800)}` : "",
5551
+ "",
5552
+ `Open ${event.agentName} in the AgenticMail web UI, or run \`claude\` / \`codex\` and the next hook fire will surface this thread.`,
5553
+ "",
5554
+ `\u2014 AgenticMail (this address is set via setup_operator_email; reply does nothing)`
5555
+ ].filter(Boolean).join("\n");
5556
+ await deps.gatewayManager.routeOutbound(bridge.name, {
5557
+ from: bridge.email,
5558
+ to: operatorEmail,
5559
+ subject: subjectLine,
5560
+ text: lines
5561
+ });
5562
+ forwarded = true;
5563
+ }
5564
+ }
5565
+ } catch (err) {
5566
+ console.warn("[bridge-escalation] forward to operator email failed:", err.message);
5567
+ }
5568
+ res.json({ ok: true, escalated: true, forwarded });
5509
5569
  });
5510
5570
  router.post("/dispatcher/worker-skipped", requireMaster, (req, res) => {
5511
5571
  const body = req.body ?? {};
@@ -5765,7 +5825,15 @@ function createApp(configOverrides) {
5765
5825
  app2.use("/api/agenticmail", createSmsRoutes(db, accountManager, config, gatewayManager));
5766
5826
  app2.use("/api/agenticmail", createStorageRoutes(db, accountManager, config));
5767
5827
  app2.use("/api/agenticmail", createSystemEventRoutes());
5768
- app2.use("/api/agenticmail", createDispatcherActivityRoutes());
5828
+ app2.use("/api/agenticmail", createDispatcherActivityRoutes({
5829
+ // Wire the gateway + account manager so /dispatcher/bridge-escalation
5830
+ // can forward a digest to the operator's notification email when
5831
+ // no host session is available for a headless resume. See
5832
+ // packages/core/src/operator-prefs.ts for the address source and
5833
+ // the setup_operator_email MCP tool for how it gets configured.
5834
+ gatewayManager,
5835
+ accountManager
5836
+ }));
5769
5837
  app2.use("/api/agenticmail", createAgentMemoryRoutes(config));
5770
5838
  app2.use("/api/agenticmail", (_req, res) => {
5771
5839
  res.status(404).json({ error: "Not found" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.25",
3
+ "version": "0.9.27",
4
4
  "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/public/index.html CHANGED
@@ -63,6 +63,8 @@
63
63
  <div class="profile-menu-section">Inboxes</div>
64
64
  <div id="profile-menu-list"></div>
65
65
  <div class="profile-menu-divider"></div>
66
+ <div id="profile-menu-operator-email"></div>
67
+ <div class="profile-menu-divider"></div>
66
68
  <div class="profile-menu-footer">
67
69
  <a id="signout-link">Sign out</a>
68
70
  </div>
package/public/js/api.js CHANGED
@@ -37,6 +37,25 @@ export async function apiPut(path, body, opts = {}) {
37
37
  return await r.json();
38
38
  }
39
39
 
40
+ export async function apiPatch(path, body, opts = {}) {
41
+ const r = await fetch(`${API_URL}/api/agenticmail${path}`, {
42
+ method: 'PATCH',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ Authorization: `Bearer ${opts.agentKey ?? state.masterKey}`,
46
+ },
47
+ body: JSON.stringify(body),
48
+ });
49
+ if (!r.ok) {
50
+ // Try to surface the server's error body so the UI can show
51
+ // "operator email must contain an @" instead of a bare 400.
52
+ let detail = '';
53
+ try { const j = await r.json(); detail = j?.error ? `: ${j.error}` : ''; } catch { /* ignore */ }
54
+ throw new Error(`${r.status} ${path}${detail}`);
55
+ }
56
+ return await r.json();
57
+ }
58
+
40
59
  /**
41
60
  * Fetch an attachment with auth and trigger a browser download.
42
61
  *
@@ -16,9 +16,10 @@
16
16
  // The selected host persists in localStorage so the operator's view
17
17
  // preference survives reloads and across browser tabs.
18
18
  import { state } from './state.js';
19
- import { escapeHtml } from './utils.js';
19
+ import { escapeHtml, toast } from './utils.js';
20
20
  import { avatarHtml, isBridgeAgent } from './avatar.js';
21
21
  import { icon } from './icons.js';
22
+ import { apiGet, apiPatch } from './api.js';
22
23
 
23
24
  /**
24
25
  * Host registry mirrors the one in avatar.js. Kept duplicated rather
@@ -339,8 +340,131 @@ export function bindHostSwitcher() {
339
340
  });
340
341
  }
341
342
 
343
+ /**
344
+ * Operator notification email — show + edit in the profile menu.
345
+ *
346
+ * The dispatcher emails this address when a sub-agent mails a host
347
+ * bridge AND no fresh host session is available for a headless
348
+ * resume. Wired to `GET / PATCH /system/operator-email` (the same
349
+ * endpoints the `setup_operator_email` MCP tool uses, so changes
350
+ * from the UI and changes from a host agent stay in sync).
351
+ *
352
+ * Two states:
353
+ * 1. Display: shows the current email or "Not set", with a small
354
+ * "Edit" pencil. Clicking the pencil swaps to state 2.
355
+ * 2. Edit: <input> + Save / Cancel buttons. Save fires PATCH,
356
+ * shows a toast, and re-renders state 1.
357
+ *
358
+ * Cached value: `operatorEmail` lives on `state.operatorEmail` so a
359
+ * re-render of the profile menu doesn't refetch every time.
360
+ */
361
+
362
+ async function loadOperatorEmail() {
363
+ try {
364
+ const r = await apiGet('/system/operator-email');
365
+ state.operatorEmail = (typeof r?.email === 'string' && r.email) ? r.email : null;
366
+ } catch (err) {
367
+ // 404 / 500 on older servers — degrade silently to "not set".
368
+ state.operatorEmail = null;
369
+ }
370
+ renderOperatorEmail();
371
+ }
372
+
373
+ function renderOperatorEmail() {
374
+ const slot = document.getElementById('profile-menu-operator-email');
375
+ if (!slot) return;
376
+ const value = state.operatorEmail;
377
+ const editing = slot.dataset.mode === 'edit';
378
+
379
+ if (editing) {
380
+ slot.innerHTML = `
381
+ <div class="profile-menu-section">Alert email</div>
382
+ <div class="operator-email-edit">
383
+ <input
384
+ type="email"
385
+ id="operator-email-input"
386
+ class="operator-email-input"
387
+ placeholder="you@example.com"
388
+ value="${escapeHtml(value ?? '')}" />
389
+ <div class="operator-email-actions">
390
+ <button class="operator-email-btn operator-email-btn-primary" id="operator-email-save">Save</button>
391
+ <button class="operator-email-btn" id="operator-email-cancel">Cancel</button>
392
+ ${value ? `<button class="operator-email-btn operator-email-btn-danger" id="operator-email-clear" title="Clear — no email forward">Clear</button>` : ''}
393
+ </div>
394
+ <div class="operator-email-hint">
395
+ Dispatcher emails this address when sub-agents mail your host bridge and the resume can't run (no fresh session, expired token). Phone push via your normal mail app.
396
+ </div>
397
+ </div>
398
+ `;
399
+ const input = document.getElementById('operator-email-input');
400
+ input?.focus();
401
+ input?.select();
402
+ document.getElementById('operator-email-save')?.addEventListener('click', () => saveOperatorEmail(input?.value ?? ''));
403
+ document.getElementById('operator-email-cancel')?.addEventListener('click', () => {
404
+ slot.dataset.mode = '';
405
+ renderOperatorEmail();
406
+ });
407
+ document.getElementById('operator-email-clear')?.addEventListener('click', () => saveOperatorEmail(''));
408
+ input?.addEventListener('keydown', (e) => {
409
+ if (e.key === 'Enter') saveOperatorEmail(input.value);
410
+ if (e.key === 'Escape') { slot.dataset.mode = ''; renderOperatorEmail(); }
411
+ });
412
+ return;
413
+ }
414
+
415
+ slot.innerHTML = `
416
+ <div class="profile-menu-section">Alert email</div>
417
+ <div class="operator-email-display" id="operator-email-display">
418
+ <div class="operator-email-value ${value ? '' : 'is-empty'}">
419
+ ${value ? escapeHtml(value) : 'Not set — sub-agent escalations stay in the web UI only'}
420
+ </div>
421
+ <button class="operator-email-edit-btn" id="operator-email-edit" title="${value ? 'Change' : 'Set up'}">
422
+ ${icon('compose', { size: 14 })}
423
+ </button>
424
+ </div>
425
+ `;
426
+ document.getElementById('operator-email-edit')?.addEventListener('click', () => {
427
+ slot.dataset.mode = 'edit';
428
+ renderOperatorEmail();
429
+ });
430
+ }
431
+
432
+ async function saveOperatorEmail(raw) {
433
+ const trimmed = String(raw ?? '').trim();
434
+ try {
435
+ // Server canonicalises (trims) and validates (must contain @ when
436
+ // non-empty). Empty string = clear, server stores null. We mirror
437
+ // the server's canonical value back into state so a refresh
438
+ // shows exactly what was persisted.
439
+ const r = await apiPatch('/system/operator-email', { email: trimmed || null });
440
+ state.operatorEmail = (typeof r?.email === 'string' && r.email) ? r.email : null;
441
+ toast(state.operatorEmail ? `Alerts now route to ${state.operatorEmail}` : 'Alert email cleared.');
442
+ const slot = document.getElementById('profile-menu-operator-email');
443
+ if (slot) slot.dataset.mode = '';
444
+ renderOperatorEmail();
445
+ } catch (err) {
446
+ toast(`Could not save: ${err.message.replace(/^\d+\s+\S+:\s*/, '')}`, true);
447
+ }
448
+ }
449
+
450
+ /** Lazy-load on first profile-menu open — keeps the initial page
451
+ * load free of an extra round-trip. Subsequent opens use the
452
+ * cached value in state.operatorEmail. */
453
+ let operatorEmailHydrated = false;
454
+ export function hydrateOperatorEmail() {
455
+ if (operatorEmailHydrated) {
456
+ renderOperatorEmail();
457
+ return;
458
+ }
459
+ operatorEmailHydrated = true;
460
+ void loadOperatorEmail();
461
+ }
462
+
342
463
  export function toggleProfileMenu(e) {
343
464
  if (e) e.stopPropagation();
465
+ // Lazy-hydrate the operator email block the first time the menu
466
+ // opens (idempotent for subsequent opens — re-renders from cache).
467
+ hydrateOperatorEmail();
344
468
  document.getElementById('profile-menu').classList.toggle('open');
345
469
  }
346
470
  export function closeProfileMenu() {
@@ -48,6 +48,16 @@ export const state = {
48
48
  * `state.agents` so no UI work is needed when a new bridge appears.
49
49
  */
50
50
  activeHost: localStorage.getItem('agenticmail.activeHost') || 'all',
51
+ /**
52
+ * Operator's notification email address (or null when not set).
53
+ * Hydrated lazily on first profile-menu open via
54
+ * `GET /system/operator-email`. Used by the dispatcher's
55
+ * bridge-escalation path to forward digests when a sub-agent
56
+ * mails a bridge and no host session is resumable. See
57
+ * packages/core/src/operator-prefs.ts for the storage and
58
+ * profile.js for the in-menu edit affordance.
59
+ */
60
+ operatorEmail: null,
51
61
  };
52
62
 
53
63
  export const API_URL = window.location.origin;
package/public/styles.css CHANGED
@@ -446,6 +446,109 @@ a { color: var(--accent-strong); }
446
446
  }
447
447
  .profile-menu-footer a:hover { text-decoration: underline; }
448
448
 
449
+ /* ─── Operator notification email — display + inline edit ──────────
450
+ *
451
+ * Lives between the inbox list and the Sign out footer. Two states:
452
+ * - Display: current address (or "Not set" placeholder) + pencil
453
+ * - Edit: <input> + Save / Cancel / Clear buttons + hint copy
454
+ *
455
+ * Visual weight is deliberately understated — this is a "set once,
456
+ * forget" knob, not a primary action. */
457
+ .operator-email-display {
458
+ padding: 6px 20px 10px;
459
+ display: flex; align-items: center; gap: 8px;
460
+ font-size: 13px;
461
+ }
462
+ .operator-email-value {
463
+ flex: 1;
464
+ color: var(--ink);
465
+ overflow: hidden;
466
+ text-overflow: ellipsis;
467
+ white-space: nowrap;
468
+ }
469
+ .operator-email-value.is-empty {
470
+ color: var(--muted);
471
+ font-style: italic;
472
+ font-size: 12px;
473
+ }
474
+ .operator-email-edit-btn {
475
+ background: transparent;
476
+ border: none;
477
+ cursor: pointer;
478
+ color: var(--muted);
479
+ padding: 4px;
480
+ border-radius: 4px;
481
+ display: flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ }
485
+ .operator-email-edit-btn:hover {
486
+ background: var(--bg-hover);
487
+ color: var(--ink);
488
+ }
489
+ .operator-email-edit {
490
+ padding: 4px 20px 12px;
491
+ }
492
+ .operator-email-input {
493
+ width: 100%;
494
+ padding: 8px 10px;
495
+ border: 1px solid var(--line);
496
+ border-radius: 6px;
497
+ background: var(--bg);
498
+ color: var(--ink);
499
+ font-size: 13px;
500
+ font-family: inherit;
501
+ box-sizing: border-box;
502
+ }
503
+ .operator-email-input:focus {
504
+ outline: none;
505
+ border-color: var(--accent-strong);
506
+ }
507
+ .operator-email-actions {
508
+ display: flex;
509
+ gap: 6px;
510
+ margin-top: 8px;
511
+ }
512
+ .operator-email-btn {
513
+ padding: 6px 12px;
514
+ border: 1px solid var(--line);
515
+ border-radius: 6px;
516
+ background: var(--bg);
517
+ color: var(--ink);
518
+ font-size: 12px;
519
+ font-weight: 500;
520
+ cursor: pointer;
521
+ transition: background 120ms, border-color 120ms;
522
+ }
523
+ .operator-email-btn:hover { background: var(--bg-hover); }
524
+ .operator-email-btn-primary {
525
+ background: var(--accent-strong);
526
+ color: white;
527
+ border-color: var(--accent-strong);
528
+ }
529
+ .operator-email-btn-primary:hover {
530
+ background: var(--accent-strong);
531
+ opacity: 0.9;
532
+ }
533
+ .operator-email-btn-danger {
534
+ margin-left: auto;
535
+ color: #b91c1c;
536
+ border-color: transparent;
537
+ }
538
+ .operator-email-btn-danger:hover {
539
+ background: #fee2e2;
540
+ }
541
+ .operator-email-hint {
542
+ margin-top: 8px;
543
+ font-size: 11px;
544
+ color: var(--muted);
545
+ line-height: 1.45;
546
+ }
547
+ @media (prefers-color-scheme: dark) {
548
+ .operator-email-btn-danger { color: #fca5a5; }
549
+ .operator-email-btn-danger:hover { background: #2a1717; }
550
+ }
551
+
449
552
  /* ─── Avatars ──────────────────────────────────────────────────────── */
450
553
  .avatar {
451
554
  width: 32px; height: 32px; border-radius: 50%;