@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 +72 -4
- package/package.json +1 -1
- package/public/index.html +2 -0
- package/public/js/api.js +19 -0
- package/public/js/profile.js +125 -1
- package/public/js/state.js +10 -0
- package/public/styles.css +103 -0
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
|
-
|
|
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
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
|
*
|
package/public/js/profile.js
CHANGED
|
@@ -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() {
|
package/public/js/state.js
CHANGED
|
@@ -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%;
|