@agenticmail/api 0.9.26 → 0.9.28

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/README.md CHANGED
@@ -408,6 +408,17 @@ JSON parse errors (malformed request bodies) return a clear 400 error rather tha
408
408
 
409
409
  ---
410
410
 
411
+ ## External inbox exposure — what `/gateway/relay` actually opens up
412
+
413
+ > **The `POST /gateway/relay` endpoint (the one `agenticmail setup-email` calls) makes every sub-agent publicly reachable from the internet via plus-addressing.** This is by design — agents that can only email each other aren't very useful for talking to real people — but the implications surprise some operators:
414
+
415
+ - **Plus-addresses are publicly guessable.** Once relay is connected, anyone can hit `your-relay+secretary@gmail.com`, `your-relay+kepler@gmail.com`, etc. and the corresponding agent's AgenticMail inbox receives the message. The `+sub` part is not a secret.
416
+ - **External mail wakes the dispatcher identically to internal `@localhost` mail.** The API publishes the same SSE `new-mail` event regardless of source; the host integration (`@agenticmail/claudecode`, `@agenticmail/codex`) spawns a worker turn either way.
417
+ - **The host bridges take a different path.** Mail to `your-relay+claudecode@gmail.com` / `your-relay+codex@gmail.com` routes to `handleBridgeMail` in the dispatcher, which uses the host SDK's `resume` option to wake the operator's last session headlessly. If that fails it falls through to the bridge-escalation email configured via `setup_operator_email`.
418
+ - **Spam = worker turns.** Throttles in order of escalation: the `wake-budget` guard in `dispatcher.handleEvent` (automatic, default cap per minute per agent), the built-in relay-level spam filter (runs before publishing the SSE event), and `metadata.host`-based fencing for agents that should stay internal-only.
419
+
420
+ ---
421
+
411
422
  ## License
412
423
 
413
424
  [MIT](./LICENSE) - Ope Olatunji ([@ope-olatunji](https://github.com/ope-olatunji))
package/dist/index.js CHANGED
@@ -1956,18 +1956,45 @@ function normalizeWakeList(value) {
1956
1956
  return void 0;
1957
1957
  }
1958
1958
  function deriveDefaultWakeList(toField) {
1959
- if (!toField) return void 0;
1960
- const arr = Array.isArray(toField) ? toField : String(toField).split(",");
1961
- const localNames = [];
1959
+ const localNames = extractLocalNames(toField);
1960
+ return localNames.length > 0 ? localNames : void 0;
1961
+ }
1962
+ function extractLocalNames(field) {
1963
+ if (!field) return [];
1964
+ const arr = Array.isArray(field) ? field : String(field).split(",");
1965
+ const out = [];
1962
1966
  for (const raw of arr) {
1963
1967
  const trimmed = String(raw).slice(0, 500).trim().toLowerCase();
1964
1968
  const m = trimmed.match(/<([^>]+)>/);
1965
1969
  const bare = (m ? m[1] : trimmed).trim();
1966
1970
  if (!bare.endsWith("@localhost")) continue;
1967
1971
  const name = bare.replace(/@localhost$/i, "");
1968
- if (name) localNames.push(name);
1972
+ if (name) out.push(name);
1969
1973
  }
1970
- return localNames.length > 0 ? localNames : void 0;
1974
+ return out;
1975
+ }
1976
+ function deriveWakeFromBody(body, candidateNames) {
1977
+ if (!body || candidateNames.length === 0) return [];
1978
+ const sample = body.length > 2e4 ? body.slice(0, 2e4) : body;
1979
+ const found = /* @__PURE__ */ new Set();
1980
+ for (const raw of candidateNames) {
1981
+ const name = String(raw).trim().toLowerCase();
1982
+ if (!name) continue;
1983
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1984
+ const patterns = [
1985
+ // Greeting / handoff anchors:
1986
+ // "Marlow —" "Marlow:" "Marlow,"
1987
+ new RegExp(`(?:^|[\\n.])\\s*${esc}\\s*[\u2014\u2013\\-:,]`, "i"),
1988
+ // Mention syntax:
1989
+ // "@marlow"
1990
+ new RegExp(`@${esc}\\b`, "i"),
1991
+ // Conversational handoff phrases:
1992
+ // "over to marlow" "handing off to marlow" "next up: marlow"
1993
+ new RegExp(`\\b(?:hi|hey|hello|over to|hand(?:ing)? off to|dispatch(?:ing)? to|assigning to|next up:?|next slice:?)\\s+${esc}\\b`, "i")
1994
+ ];
1995
+ if (patterns.some((p) => p.test(sample))) found.add(name);
1996
+ }
1997
+ return Array.from(found);
1971
1998
  }
1972
1999
  function wakeHeaders(wakeList) {
1973
2000
  if (wakeList === void 0) return {};
@@ -2113,7 +2140,13 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
2113
2140
  const ownerName2 = agent.metadata?.ownerName;
2114
2141
  const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
2115
2142
  const explicitWakeForPersist = normalizeWakeList(wake);
2116
- const wakeListForPersist = wake === void 0 ? deriveDefaultWakeList(to) : explicitWakeForPersist;
2143
+ let wakeListForPersist;
2144
+ if (wake !== void 0) {
2145
+ wakeListForPersist = explicitWakeForPersist;
2146
+ } else {
2147
+ const bodyDerived = deriveWakeFromBody(typeof text === "string" ? text : "", extractLocalNames(cc));
2148
+ wakeListForPersist = bodyDerived.length > 0 ? bodyDerived : deriveDefaultWakeList(to);
2149
+ }
2117
2150
  const mailOptions = {
2118
2151
  to,
2119
2152
  subject,
@@ -2197,7 +2230,13 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
2197
2230
  const ownerName = agent.metadata?.ownerName;
2198
2231
  const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
2199
2232
  const explicitWake = normalizeWakeList(wake);
2200
- const wakeList = wake === void 0 ? deriveDefaultWakeList(to) : explicitWake;
2233
+ let wakeList;
2234
+ if (wake !== void 0) {
2235
+ wakeList = explicitWake;
2236
+ } else {
2237
+ const bodyDerived = deriveWakeFromBody(typeof text === "string" ? text : "", extractLocalNames(cc));
2238
+ wakeList = bodyDerived.length > 0 ? bodyDerived : deriveDefaultWakeList(to);
2239
+ }
2201
2240
  const customHeaders = wakeHeaders(wakeList);
2202
2241
  const mailOpts = {
2203
2242
  to,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.26",
3
+ "version": "0.9.28",
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%;