@agenticmail/api 0.9.2 → 0.9.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
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
@@ -39,6 +39,7 @@
39
39
  <button id="search-clear" class="search-clear-btn" title="Clear (Esc)" data-icon="close"></button>
40
40
  </div>
41
41
  <div class="topbar-right">
42
+ <button class="icon-btn" id="sound-toggle-btn" title="Notification sound"></button>
42
43
  <button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
43
44
  <button id="profile-btn" class="profile-trigger" title="Account">
44
45
  <span id="profile-avatar"></span>
package/public/js/app.js CHANGED
@@ -15,6 +15,7 @@ import { openMessage } from './message-view.js';
15
15
  import { populateComposeFrom, openCompose, openDraft, closeCompose, discardCompose, sendCompose } from './compose.js';
16
16
  import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
17
17
  import { icon } from './icons.js';
18
+ import { isSoundEnabled, setSoundEnabled, playNotificationSound } from './sound.js';
18
19
 
19
20
  // Hydrate every `data-icon="name"` placeholder in the static HTML
20
21
  // with the corresponding inline SVG. Done once on load so we don't
@@ -188,6 +189,27 @@ document.getElementById('sidebar-backdrop').addEventListener('click', () => {
188
189
  document.getElementById('main')?.classList.remove('sidebar-open');
189
190
  });
190
191
 
192
+ // Sound toggle. Stateful icon button — bell (on) / bell-slash (off).
193
+ // Clicking flips the preference (persisted to localStorage by the
194
+ // sound module), updates the icon, and plays a single test chime
195
+ // on transitions to ON so the user hears what they just enabled.
196
+ function renderSoundToggle() {
197
+ const btn = document.getElementById('sound-toggle-btn');
198
+ if (!btn) return;
199
+ const on = isSoundEnabled();
200
+ btn.innerHTML = icon(on ? 'soundOn' : 'soundOff', { size: 18 });
201
+ btn.title = on ? 'Notification sound: on (click to mute)' : 'Notification sound: off (click to enable)';
202
+ btn.classList.toggle('sound-on', on);
203
+ btn.classList.toggle('sound-off', !on);
204
+ }
205
+ document.getElementById('sound-toggle-btn')?.addEventListener('click', () => {
206
+ const next = !isSoundEnabled();
207
+ setSoundEnabled(next);
208
+ renderSoundToggle();
209
+ if (next) playNotificationSound(); // sample the chime on enable
210
+ });
211
+ renderSoundToggle();
212
+
191
213
  document.getElementById('refresh-btn').addEventListener('click', async () => {
192
214
  if (state.selectedAgent) {
193
215
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -47,6 +47,13 @@ const PATHS = {
47
47
  // ─── Status / dots ──────────────────────────────────────────────
48
48
  dot: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
49
49
  check: 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z',
50
+ // Material-style notifications bell — used for the sound-on
51
+ // state. Off uses the same path with a diagonal slash overlay
52
+ // applied via CSS (.icon-svg.off → ::after rule in styles.css).
53
+ soundOn: 'M12 22a2 2 0 0 0 2-2h-4a2 2 0 0 0 2 2zm6-6V11c0-3.07-1.64-5.64-4.5-6.32V4a1.5 1.5 0 0 0-3 0v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z',
54
+ // Bell with a slash through it (off state). Single-path so the
55
+ // icon hot-swaps cleanly without dependency on overlay rules.
56
+ soundOff: 'M16.27 19.27 18 21l-1.27 1.27-1.45-1.45A1.98 1.98 0 0 1 14 22h-4a2 2 0 0 1-2-2h4a2 2 0 0 0 .27-.27L3 9.46 4.27 8.19 16.27 19.27zM18 16v-5a6 6 0 0 0-4.5-5.81V5a1.5 1.5 0 0 0-3 0v.19a6 6 0 0 0-2.16.91L18 16z',
50
57
  };
51
58
 
52
59
  export function icon(name, opts = {}) {
@@ -214,6 +214,48 @@ async function loadDraftsList(agent) {
214
214
  }
215
215
  }
216
216
 
217
+ /**
218
+ * Refresh the currently-rendered list without rebuilding the
219
+ * toolbar. Used by the SSE new-mail handler so a new email
220
+ * silently slides into the list — no flicker, no "Loading…",
221
+ * no bulk-action selection wiped, no scroll jump.
222
+ *
223
+ * Drafts have their own loader; everything else uses the
224
+ * generic digest path. Falls through to a noop on errors
225
+ * (the SSE notification already pinged the user, so a silent
226
+ * refresh failure is acceptable — the next user-driven
227
+ * refresh / folder switch will repair).
228
+ */
229
+ export async function silentRefresh(agent, folder) {
230
+ if (!agent) return;
231
+ try {
232
+ if (folder === 'drafts') {
233
+ const data = await apiGet('/drafts', { agentKey: agent.apiKey });
234
+ const rows = Array.isArray(data?.drafts) ? data.drafts : [];
235
+ state.messages = rows.map(r => ({
236
+ uid: r.id,
237
+ __draftId: r.id,
238
+ subject: r.subject || '(no subject)',
239
+ from: [{ name: agent.name, address: agent.email }],
240
+ date: r.updated_at ? `${r.updated_at}Z`.replace('ZZ', 'Z') : null,
241
+ preview: (r.text_body || '').slice(0, 240),
242
+ flags: [],
243
+ __recipient: r.to_addr || '(no recipient)',
244
+ }));
245
+ renderList();
246
+ return;
247
+ }
248
+ await ensureFolderCache(agent);
249
+ const isStarred = folder === 'starred';
250
+ const imap = isStarred ? 'INBOX' : (state.folderNames?.[folder]);
251
+ if (!imap) return;
252
+ const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=50&offset=0&previewLength=240`;
253
+ const data = await apiGet(url, { agentKey: agent.apiKey });
254
+ state.messages = data.messages ?? [];
255
+ renderList();
256
+ } catch { /* silent — next user action will repair */ }
257
+ }
258
+
217
259
  export function renderList() {
218
260
  const root = document.getElementById('list-rows');
219
261
  if (!root) return;
@@ -0,0 +1,61 @@
1
+ // Notification sound — soft 2-note chime synthesised via Web Audio
2
+ // API (no external asset shipped, zero network cost). User
3
+ // preference (on/off) lives in localStorage.
4
+ //
5
+ // Why Web Audio and not an <audio src="...">: the asset would have
6
+ // to be bundled (cache invalidation, MIME types, paths under
7
+ // `/branding/`, etc.), and we'd still need code to gate playback
8
+ // on a user-toggle. Synthesizing a chime is one short function
9
+ // with no asset surface and lets us tweak the timbre by editing
10
+ // numbers.
11
+
12
+ const STORAGE_KEY = 'agenticmail.notif.soundEnabled';
13
+
14
+ /** True if the user has the chime turned on. Defaults to true. */
15
+ export function isSoundEnabled() {
16
+ // null = never set → default ON. 'false' string = explicitly off.
17
+ return localStorage.getItem(STORAGE_KEY) !== 'false';
18
+ }
19
+
20
+ /** Persist the user's choice. */
21
+ export function setSoundEnabled(enabled) {
22
+ try { localStorage.setItem(STORAGE_KEY, enabled ? 'true' : 'false'); } catch { /* private mode */ }
23
+ }
24
+
25
+ /**
26
+ * Play the new-mail chime. Two short sine pulses an octave apart
27
+ * (E5 → A5), 220 ms total, gain envelope quick attack + 60 ms
28
+ * decay so it reads as a soft "ding" rather than a buzz. Bails
29
+ * silently when sound is disabled or the browser blocks audio
30
+ * (e.g. tab hasn't received a user gesture yet — first arrival
31
+ * after a page load with no interaction may be muted by the
32
+ * autoplay policy; subsequent arrivals work).
33
+ */
34
+ export function playNotificationSound() {
35
+ if (!isSoundEnabled()) return;
36
+ try {
37
+ const Ctor = window.AudioContext || window.webkitAudioContext;
38
+ if (!Ctor) return;
39
+ const ctx = new Ctor();
40
+ const now = ctx.currentTime;
41
+ const playTone = (freq, startOffset, duration = 0.08) => {
42
+ const osc = ctx.createOscillator();
43
+ const gain = ctx.createGain();
44
+ osc.type = 'sine';
45
+ osc.frequency.value = freq;
46
+ const start = now + startOffset;
47
+ // Quick attack + exponential decay = "chime" not "beep".
48
+ gain.gain.setValueAtTime(0.0001, start);
49
+ gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
50
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
51
+ osc.connect(gain).connect(ctx.destination);
52
+ osc.start(start);
53
+ osc.stop(start + duration);
54
+ };
55
+ playTone(659.25, 0); // E5
56
+ playTone(880.00, 0.12); // A5 — major sixth above
57
+ // Close the context shortly after the tones end so we don't
58
+ // leak audio contexts. Some browsers cap at ~6 concurrent.
59
+ setTimeout(() => { try { ctx.close(); } catch {} }, 600);
60
+ } catch { /* audio blocked; user-toggle is still respected */ }
61
+ }
package/public/js/sse.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // Real-time mail delivery via Server-Sent Events. Every agent gets
2
2
  // its own subscription; the dispatcher pushes a `new` event per
3
3
  // arrived message. We fan that out to:
4
- // 1. List view — reload if it's the active inbox (instant ping)
4
+ // 1. List view — silent in-place refresh (no flicker, no scroll
5
+ // jump, no bulk-selection wipe) if it's the active inbox
5
6
  // 2. Profile dropdown — bump the per-agent unread counter
6
7
  // 3. Browser notification — system ping when the tab is in the background
8
+ // 4. Soft chime (toggleable) when sound is enabled
7
9
  import { state, API_URL } from './state.js';
8
10
  import { toast } from './utils.js';
9
11
  import { renderProfile } from './profile.js';
10
- import { loadList } from './list-view.js';
12
+ import { silentRefresh } from './list-view.js';
13
+ import { playNotificationSound } from './sound.js';
11
14
 
12
15
  export function subscribeToAllAgents() {
13
16
  // Tear down previous controllers (called on agent-list refresh).
@@ -49,11 +52,23 @@ async function handleSseEvent(agent, event) {
49
52
 
50
53
  const isOpen = state.selectedAgent?.id === agent.id;
51
54
  if (isOpen) {
52
- await loadList(agent, state.selectedFolder);
55
+ // Silent in-place refresh — re-fetches the list digest and
56
+ // re-renders ONLY the rows div. Toolbar (select-all, refresh,
57
+ // bulk-actions) is untouched; existing row checkboxes survive;
58
+ // scroll position is preserved by the browser since we replace
59
+ // only the inner content. No "Loading…" flicker.
60
+ await silentRefresh(agent, state.selectedFolder);
53
61
  state.unread[agent.id] = 0; // user is looking — clear badge
54
62
  renderProfile();
55
63
  }
56
64
 
65
+ // Soft chime — respects the user's sound toggle. Plays for every
66
+ // arrival regardless of whether the tab is focused, because that
67
+ // is the whole point of the chime (a foregrounded tab still
68
+ // benefits from the audible ping when the user's attention is
69
+ // elsewhere on screen).
70
+ playNotificationSound();
71
+
57
72
  fireBrowserNotification(agent, event, isOpen);
58
73
 
59
74
  if (!isOpen) {
package/public/styles.css CHANGED
@@ -159,6 +159,10 @@ a { color: var(--accent-strong); }
159
159
  color: var(--ink-soft); font-size: 18px;
160
160
  }
161
161
  .icon-btn:hover { background: var(--bg-hover); }
162
+ /* Sound-toggle state colours. On = brand pink (alive); off = muted
163
+ so the button reads as "currently silent". */
164
+ #sound-toggle-btn.sound-on { color: var(--pink); }
165
+ #sound-toggle-btn.sound-off { color: var(--muted); }
162
166
 
163
167
  /* ─── Profile (top right) ─────────────────────────────────────────── */
164
168
  .profile-trigger {