@agenticmail/api 0.7.9 → 0.7.12

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
@@ -7,8 +7,8 @@ import express from "express";
7
7
  import cors from "cors";
8
8
  import rateLimit from "express-rate-limit";
9
9
  import { fileURLToPath as fileURLToPath2 } from "url";
10
- import { dirname as dirname2, join as join2 } from "path";
11
- import { existsSync } from "fs";
10
+ import { dirname as dirname2, join as join3 } from "path";
11
+ import { existsSync as existsSync2 } from "fs";
12
12
  import {
13
13
  resolveConfig,
14
14
  getDatabase,
@@ -4680,15 +4680,15 @@ function createStorageRoutes(rawDb, accountManager2, config, dialect = "sqlite")
4680
4680
 
4681
4681
  // src/routes/dispatcher-activity.ts
4682
4682
  import { Router as Router13 } from "express";
4683
- var ACTIVE_TTL_MS = 30 * 60 * 1e3;
4683
+ import { existsSync, readFileSync as readFileSync2, statSync } from "fs";
4684
+ import { homedir } from "os";
4685
+ import { join as join2 } from "path";
4686
+ var STALE_HEARTBEAT_MS = 90 * 1e3;
4684
4687
  var RECENT_TTL_MS = 2 * 60 * 1e3;
4685
4688
  var HARD_CAP = 256;
4686
4689
  var active = /* @__PURE__ */ new Map();
4687
4690
  var recent = /* @__PURE__ */ new Map();
4688
4691
  function prune(nowMs) {
4689
- for (const [id, w] of active) {
4690
- if (nowMs - w.startedAtMs > ACTIVE_TTL_MS) active.delete(id);
4691
- }
4692
4692
  for (const [id, w] of recent) {
4693
4693
  const t = w.endedAtMs ?? w.startedAtMs;
4694
4694
  if (nowMs - t > RECENT_TTL_MS) recent.delete(id);
@@ -4718,7 +4718,9 @@ function createDispatcherActivityRoutes() {
4718
4718
  agentEmail: typeof body.agentEmail === "string" ? body.agentEmail : void 0,
4719
4719
  kind: typeof body.kind === "string" ? body.kind : "unknown",
4720
4720
  trigger: body.trigger && typeof body.trigger === "object" ? body.trigger : void 0,
4721
- startedAtMs: Date.now()
4721
+ startedAtMs: Date.now(),
4722
+ lastHeartbeatMs: Date.now(),
4723
+ turnCount: 0
4722
4724
  };
4723
4725
  prune(info.startedAtMs);
4724
4726
  active.set(info.workerId, info);
@@ -4748,7 +4750,8 @@ function createDispatcherActivityRoutes() {
4748
4750
  },
4749
4751
  endedAtMs: nowMs,
4750
4752
  ok: body.ok === false ? false : true,
4751
- resultPreview: typeof body.resultPreview === "string" ? body.resultPreview.slice(0, 240) : void 0
4753
+ resultPreview: typeof body.resultPreview === "string" ? body.resultPreview.slice(0, 240) : void 0,
4754
+ turnCount: typeof body.turnCount === "number" ? body.turnCount : existing?.turnCount
4752
4755
  };
4753
4756
  active.delete(body.workerId);
4754
4757
  recent.set(body.workerId, info);
@@ -4762,6 +4765,22 @@ function createDispatcherActivityRoutes() {
4762
4765
  }
4763
4766
  res.json({ ok: true });
4764
4767
  });
4768
+ router.post("/dispatcher/worker-heartbeat", requireMaster, (req, res) => {
4769
+ const body = req.body ?? {};
4770
+ if (typeof body.workerId !== "string") {
4771
+ res.status(400).json({ error: "workerId is required" });
4772
+ return;
4773
+ }
4774
+ const existing = active.get(body.workerId);
4775
+ if (!existing) {
4776
+ res.json({ ok: true, ignored: "unknown worker" });
4777
+ return;
4778
+ }
4779
+ existing.lastHeartbeatMs = Date.now();
4780
+ if (typeof body.lastTool === "string") existing.lastTool = body.lastTool;
4781
+ if (typeof body.turnCount === "number") existing.turnCount = body.turnCount;
4782
+ res.json({ ok: true });
4783
+ });
4765
4784
  router.get("/dispatcher/activity", requireMaster, (_req, res) => {
4766
4785
  const nowMs = Date.now();
4767
4786
  prune(nowMs);
@@ -4769,7 +4788,9 @@ function createDispatcherActivityRoutes() {
4769
4788
  now: nowMs,
4770
4789
  active: Array.from(active.values()).map((w) => ({
4771
4790
  ...w,
4772
- durationMs: nowMs - w.startedAtMs
4791
+ durationMs: nowMs - w.startedAtMs,
4792
+ stale: w.lastHeartbeatMs !== void 0 && nowMs - w.lastHeartbeatMs > STALE_HEARTBEAT_MS,
4793
+ heartbeatAgeMs: w.lastHeartbeatMs !== void 0 ? nowMs - w.lastHeartbeatMs : void 0
4773
4794
  })),
4774
4795
  recent: Array.from(recent.values()).map((w) => ({
4775
4796
  ...w,
@@ -4777,6 +4798,35 @@ function createDispatcherActivityRoutes() {
4777
4798
  }))
4778
4799
  });
4779
4800
  });
4801
+ router.get("/dispatcher/worker-log/:workerId", requireMaster, (req, res) => {
4802
+ const rawId = String(req.params.workerId ?? "");
4803
+ if (!rawId) {
4804
+ res.status(400).json({ error: "workerId is required" });
4805
+ return;
4806
+ }
4807
+ const lines = Math.min(Math.max(Number(req.query.lines ?? 80), 1), 1e3);
4808
+ const safe = rawId.replace(/[^a-zA-Z0-9._-]/g, "_");
4809
+ const path = join2(homedir(), ".agenticmail", "worker-logs", `${safe}.log`);
4810
+ if (!existsSync(path)) {
4811
+ res.status(404).json({ error: "no log file for that workerId" });
4812
+ return;
4813
+ }
4814
+ try {
4815
+ const raw = readFileSync2(path, "utf-8");
4816
+ const stat = statSync(path);
4817
+ const all = raw.split(/\r?\n/);
4818
+ const tail = all.filter(Boolean).slice(-lines);
4819
+ res.json({
4820
+ workerId: rawId,
4821
+ path,
4822
+ bytes: stat.size,
4823
+ lines: tail.length,
4824
+ tail
4825
+ });
4826
+ } catch (err) {
4827
+ res.status(500).json({ error: err.message });
4828
+ }
4829
+ });
4780
4830
  return router;
4781
4831
  }
4782
4832
 
@@ -4852,15 +4902,15 @@ function createApp(configOverrides) {
4852
4902
  const staticDir = (() => {
4853
4903
  const here = dirname2(fileURLToPath2(import.meta.url));
4854
4904
  const candidates = [
4855
- join2(here, "..", "public"),
4856
- join2(here, "public")
4905
+ join3(here, "..", "public"),
4906
+ join3(here, "public")
4857
4907
  ];
4858
- for (const c of candidates) if (existsSync(c)) return c;
4908
+ for (const c of candidates) if (existsSync2(c)) return c;
4859
4909
  return null;
4860
4910
  })();
4861
4911
  if (staticDir) {
4862
4912
  app2.use("/", express.static(staticDir, { index: "index.html", extensions: ["html"] }));
4863
- app2.get("/ui", (_req, res) => res.sendFile(join2(staticDir, "index.html")));
4913
+ app2.get("/ui", (_req, res) => res.sendFile(join3(staticDir, "index.html")));
4864
4914
  }
4865
4915
  app2.use("/api/agenticmail", createHealthRoutes(stalwart));
4866
4916
  app2.use("/api/agenticmail", createInboundRoutes(accountManager2, config, gatewayManager));
@@ -4889,9 +4939,9 @@ function createApp(configOverrides) {
4889
4939
  }
4890
4940
 
4891
4941
  // src/index.ts
4892
- import { readFileSync as readFileSync2 } from "fs";
4942
+ import { readFileSync as readFileSync3 } from "fs";
4893
4943
  import { fileURLToPath as fileURLToPath3 } from "url";
4894
- import { dirname as dirname3, join as join3 } from "path";
4944
+ import { dirname as dirname3, join as join4 } from "path";
4895
4945
  await prepareIntegrations();
4896
4946
  function getLocalIp() {
4897
4947
  const nets = networkInterfaces();
@@ -4906,7 +4956,7 @@ function getLocalIp() {
4906
4956
  var VERSION = (() => {
4907
4957
  try {
4908
4958
  const __dirname = dirname3(fileURLToPath3(import.meta.url));
4909
- const pkg = JSON.parse(readFileSync2(join3(__dirname, "..", "package.json"), "utf-8"));
4959
+ const pkg = JSON.parse(readFileSync3(join4(__dirname, "..", "package.json"), "utf-8"));
4910
4960
  return pkg.version;
4911
4961
  } catch {
4912
4962
  return "0.5.31";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.9",
3
+ "version": "0.7.12",
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",
@@ -0,0 +1,2 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="73 220 158 165" fill="currentColor" aria-hidden="true"><path d="m 105.01,322.07 29.14,-16.35 0.49,-1.42 -0.49,-0.79 h -1.42 l -4.87,-0.3 -16.65,-0.45 -14.44,-0.6 -13.99,-0.75 -3.52,-0.75 -3.3,-4.35 0.34,-2.17 2.96,-1.99 4.24,0.37 9.37,0.64 14.06,0.97 10.2,0.6 15.11,1.57 h 2.4 l 0.34,-0.97 -0.82,-0.6 -0.64,-0.6 -14.55,-9.86 -15.75,-10.42 -8.25,-6 -4.46,-3.04 -2.25,-2.85 -0.97,-6.22 4.05,-4.46 5.44,0.37 1.39,0.37 5.51,4.24 11.77,9.11 15.37,11.32 2.25,1.87 0.9,-0.64 0.11,-0.45 -1.01,-1.69 -8.36,-15.11 -8.92,-15.37 -3.97,-6.37 -1.05,-3.82 c -0.37,-1.57 -0.64,-2.89 -0.64,-4.5 l 4.61,-6.26 2.55,-0.82 6.15,0.82 2.59,2.25 3.82,8.74 6.19,13.76 9.6,18.71 2.81,5.55 1.5,5.14 0.56,1.57 h 0.97 v -0.9 l 0.79,-10.54 1.46,-12.94 1.42,-16.65 0.49,-4.69 2.32,-5.62 4.61,-3.04 3.6,1.72 2.96,4.24 -0.41,2.74 -1.76,11.44 -3.45,17.92 -2.25,12 h 1.31 l 1.5,-1.5 6.07,-8.06 10.2,-12.75 4.5,-5.06 5.25,-5.59 3.37,-2.66 h 6.37 l 4.69,6.97 -2.1,7.2 -6.56,8.32 -5.44,7.05 -7.8,10.5 -4.87,8.4 0.45,0.67 1.16,-0.11 17.62,-3.75 9.52,-1.72 11.36,-1.95 5.14,2.4 0.56,2.44 -2.02,4.99 -12.15,3 -14.25,2.85 -21.22,5.02 -0.26,0.19 0.3,0.37 9.56,0.9 4.09,0.22 h 10.01 l 18.64,1.39 4.87,3.22 2.92,3.94 -0.49,3 -7.5,3.82 -10.12,-2.4 -23.62,-5.62 -8.1,-2.02 h -1.12 v 0.67 l 6.75,6.6 12.37,11.17 15.49,14.4 0.79,3.56 -1.99,2.81 -2.1,-0.3 -13.61,-10.24 -5.25,-4.61 -11.89,-10.01 h -0.79 v 1.05 l 2.74,4.01 14.47,21.75 0.75,6.67 -1.05,2.17 -3.75,1.31 -4.12,-0.75 -8.47,-11.89 -8.74,-13.39 -7.05,-12 -0.86,0.49 -4.16,44.81 -1.95,2.29 -4.5,1.72 -3.75,-2.85 -1.99,-4.61 1.99,-9.11 2.4,-11.89 1.95,-9.45 1.76,-11.74 1.05,-3.9 -0.07,-0.26 -0.86,0.11 -8.85,12.15 -13.46,18.19 -10.65,11.4 -2.55,1.01 -4.42,-2.29 0.41,-4.09 2.47,-3.64 14.74,-18.75 8.89,-11.62 5.74,-6.71 -0.04,-0.97 h -0.34 l -39.15,25.42 -6.97,0.9 -3,-2.81 0.37,-4.61 1.42,-1.5 11.77,-8.1 -0.04,0.04 z"
2
+ shape-rendering="optimizeQuality"/></svg>
package/public/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>AgenticMail</title>
7
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ec4899'%3E%3Cpath d='M12 12c-2-3-5-5-8-5-1 0-2 1-2 2s1 2 2 2c2 0 4 1 5 2-1 1-3 2-5 2-1 0-2 1-2 2s1 2 2 2c3 0 6-2 8-5 2 3 5 5 8 5 1 0 2-1 2-2s-1-2-2-2c-2 0-4-1-5-2 1-1 3-2 5-2 1 0 2-1 2-2s-1-2-2-2c-3 0-6 2-8 5zm0 2a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z'/%3E%3C/svg%3E" />
7
+ <link rel="icon" type="image/png" href="/branding/agenticmail-logo.png" />
8
8
  <link rel="stylesheet" href="styles.css" />
9
9
  </head>
10
10
  <body>
@@ -12,7 +12,7 @@
12
12
  <!-- ─── Auth gate (shown until master key is entered) ────────────── -->
13
13
  <div id="auth" class="auth-gate">
14
14
  <div class="auth-card">
15
- <h1><span data-icon="bow" data-icon-size="28"></span> AgenticMail</h1>
15
+ <h1><img src="/branding/agenticmail-logo.png" alt="AgenticMail" class="brand-logo" /> AgenticMail</h1>
16
16
  <p>Enter your master key to sign in. The key is stored locally in your browser; we never send it anywhere except to <span class="mono" id="auth-api-url"></span>.</p>
17
17
  <div id="auth-err" class="auth-err" style="display:none"></div>
18
18
  <input id="auth-key" type="password" placeholder="mk_…" autocomplete="off" autofocus />
@@ -27,7 +27,7 @@
27
27
  <header class="topbar">
28
28
  <button class="menu-btn" id="menu-btn" title="Menu" data-icon="menu"></button>
29
29
  <div class="brand">
30
- <span class="brand-bow" data-icon="bow" data-icon-size="28"></span>
30
+ <img src="/branding/agenticmail-logo.png" alt="AgenticMail" class="brand-logo" />
31
31
  <span class="brand-name">AgenticMail</span>
32
32
  </div>
33
33
  <div class="search-container">
@@ -52,8 +52,9 @@
52
52
  </header>
53
53
 
54
54
  <!-- Sidebar + content -->
55
- <div class="main">
56
- <aside class="sidebar">
55
+ <div class="main" id="main">
56
+ <div class="sidebar-backdrop" id="sidebar-backdrop"></div>
57
+ <aside class="sidebar" id="sidebar">
57
58
  <button class="compose-btn" id="compose-btn">
58
59
  <span class="compose-icon" data-icon="compose" data-icon-size="22"></span>
59
60
  <span class="compose-text">Compose</span>
package/public/js/app.js CHANGED
@@ -104,6 +104,9 @@ function onFolderSelect(folder) {
104
104
  renderSidebar(onFolderSelect);
105
105
  location.hash = '#/inbox'; // any folder uses the list route
106
106
  if (state.selectedAgent) loadList(state.selectedAgent, folder);
107
+ // On mobile (the only viewport where the sidebar is over-canvas),
108
+ // close it after a folder pick so the user sees the list.
109
+ document.getElementById('main')?.classList.remove('sidebar-open');
107
110
  }
108
111
 
109
112
  // ─── Hash router ─────────────────────────────────────────────────────
@@ -119,6 +122,18 @@ function route() {
119
122
  window.addEventListener('hashchange', route);
120
123
 
121
124
  // ─── Top bar wiring ──────────────────────────────────────────────────
125
+ // Hamburger toggles the sidebar on mobile. On desktop the sidebar
126
+ // is always visible; the class only changes anything below 800 px,
127
+ // where the CSS slides it off-canvas by default.
128
+ function toggleSidebar() {
129
+ const main = document.getElementById('main');
130
+ main?.classList.toggle('sidebar-open');
131
+ }
132
+ document.getElementById('menu-btn').addEventListener('click', toggleSidebar);
133
+ document.getElementById('sidebar-backdrop').addEventListener('click', () => {
134
+ document.getElementById('main')?.classList.remove('sidebar-open');
135
+ });
136
+
122
137
  document.getElementById('refresh-btn').addEventListener('click', async () => {
123
138
  if (state.selectedAgent) {
124
139
  await loadList(state.selectedAgent, state.selectedFolder);
@@ -172,12 +187,18 @@ document.getElementById('search-clear').addEventListener('click', clearSearch);
172
187
  // r refresh current inbox
173
188
  // c compose new
174
189
  // / focus the search box
190
+ //
191
+ // IMPORTANT: every shortcut bails when ANY modifier key is held
192
+ // (Cmd / Ctrl / Alt / Meta) — otherwise Cmd+C "copy" was opening
193
+ // the compose modal, Cmd+R was overriding browser refresh, etc.
194
+ // Plain unmodified single-key shortcuts only.
175
195
  document.addEventListener('keydown', e => {
176
196
  if (document.getElementById('compose-bg').style.display !== 'none') return;
177
197
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
198
+ if (e.metaKey || e.ctrlKey || e.altKey) return; // never hijack OS shortcuts
178
199
  if (e.key === 'r') document.getElementById('refresh-btn').click();
179
- if (e.key === 'c') openCompose();
180
- if (e.key === '/') {
200
+ else if (e.key === 'c') openCompose();
201
+ else if (e.key === '/') {
181
202
  e.preventDefault();
182
203
  searchInput.focus();
183
204
  }
@@ -1,17 +1,18 @@
1
1
  // Agent identity + avatar helpers.
2
2
  //
3
3
  // The bridge agent (default name "claudecode") is the host's identity
4
- // inside AgenticMail. We render it with a stylised Claude-asterisk
5
- // mark and a green verified-tick so the host inbox is recognisable at
6
- // a glance vs. teammate sub-agents.
7
- //
8
- // We deliberately do NOT embed Anthropic's actual trademarked Claude
9
- // logo here — reproducing it pixel-for-pixel in third-party software
10
- // has licensing implications. The stylised approximation conveys
11
- // the same identity cue without the trademark concern.
4
+ // inside AgenticMail. We render it with the OFFICIAL Claude starburst
5
+ // mark (sourced from the public Wikipedia SVG, served as a static
6
+ // asset under /branding/claude-mark.svg) and a green verified-tick so
7
+ // the host inbox is recognisable at a glance vs. teammate sub-agents.
12
8
  import { escapeHtml } from './utils.js';
13
9
  import { icon } from './icons.js';
14
10
 
11
+ // Official Claude mark, served as a static asset under /branding/.
12
+ // Using <img src=...> rather than inlining the path keeps the SVG
13
+ // out of every avatar render and lets the browser cache the asset.
14
+ const CLAUDE_MARK_URL = '/branding/claude-mark.svg';
15
+
15
16
  export function isBridgeAgent(agent) {
16
17
  if (!agent) return false;
17
18
  const name = (agent.name ?? '').toLowerCase();
@@ -31,14 +32,10 @@ function avatarColorFor(name) {
31
32
  return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
32
33
  }
33
34
 
34
- const CLAUDE_MARK_SVG = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
35
- <path d="M12 1.5 L13.2 8.6 L19.5 6.6 L15 12 L19.5 17.4 L13.2 15.4 L12 22.5 L10.8 15.4 L4.5 17.4 L9 12 L4.5 6.6 L10.8 8.6 Z"/>
36
- </svg>`;
37
-
38
35
  export function avatarHtml(agent, size = '') {
39
36
  const cls = `avatar ${size}`.trim();
40
37
  if (isBridgeAgent(agent)) {
41
- return `<span class="${cls} avatar-host">${CLAUDE_MARK_SVG}<span class="avatar-check">${icon('check', { size: 10 })}</span></span>`;
38
+ return `<span class="${cls} avatar-host"><img src="${CLAUDE_MARK_URL}" alt="Claude" class="avatar-img" /><span class="avatar-check">${icon('check', { size: 10 })}</span></span>`;
42
39
  }
43
40
  const initial = (agent.name ?? '?').slice(0, 1).toUpperCase();
44
41
  const color = avatarColorFor(agent.name ?? '');
@@ -8,6 +8,44 @@ import { apiGet } from './api.js';
8
8
  import { FOLDERS } from './sidebar.js';
9
9
  import { icon } from './icons.js';
10
10
 
11
+ /**
12
+ * Defensive flag check. The API's IMAP layer returns `flags` as an
13
+ * array of strings most of the time (`['\\Seen', '\\Flagged']`) but
14
+ * some envelopes come back with a Set-like serialisation or even an
15
+ * object map. Without this guard, calling `.includes()` on a non-
16
+ * array crashed the list with "(m.flags ?? []).includes is not a
17
+ * function". Coerce everything we don't recognise to an empty list.
18
+ */
19
+ function flagsHas(flags, name) {
20
+ if (Array.isArray(flags)) return flags.includes(name);
21
+ if (flags && typeof flags === 'object') {
22
+ // `{Seen: true, Flagged: false}` shape — try both with and
23
+ // without the leading backslash since callers can mean either.
24
+ const key = name.replace(/^\\/, '');
25
+ return flags[name] === true || flags[key] === true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ // Map sidebar folder ids to the actual IMAP folder names the API
31
+ // expects on `/mail/folders/:folder`. `inbox` is special — the API
32
+ // has a dedicated `/mail/inbox` endpoint with extra enrichment, so
33
+ // we use that. Other folders go through the generic listing.
34
+ //
35
+ // Stalwart uses the standard IMAP names: INBOX, Sent, Drafts, Junk
36
+ // Mail (a.k.a. "Spam"), Trash. We use the canonical IMAP capitalisation.
37
+ const FOLDER_TO_IMAP = {
38
+ inbox: { endpoint: '/mail/inbox' },
39
+ sent: { endpoint: '/mail/folders/Sent' },
40
+ drafts: { endpoint: '/mail/folders/Drafts' },
41
+ spam: { endpoint: '/mail/folders/Junk%20Mail' },
42
+ trash: { endpoint: '/mail/folders/Trash' },
43
+ all: { endpoint: '/mail/folders/All%20Mail' },
44
+ // Starred is not a folder — it's the IMAP \Flagged flag, surfaced
45
+ // by client-side filtering over the inbox listing (Gmail-style).
46
+ starred: { endpoint: '/mail/inbox', clientFilter: 'flagged' },
47
+ };
48
+
11
49
  export async function loadList(agent, folder) {
12
50
  const root = document.getElementById('content');
13
51
  root.innerHTML = `
@@ -17,17 +55,19 @@ export async function loadList(agent, folder) {
17
55
  </div>
18
56
  <div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
19
57
  `;
58
+ const route = FOLDER_TO_IMAP[folder] ?? FOLDER_TO_IMAP.inbox;
20
59
  try {
21
- // Public API today only exposes the inbox listing. Other folders
22
- // fall through to the inbox endpoint and apply a client-side
23
- // shape (e.g. starred = flag filter). When the API grows
24
- // per-mailbox listing we'll route based on `folder` here.
25
- const data = await apiGet('/mail/inbox?limit=50&offset=0', { agentKey: agent.apiKey });
60
+ const sep = route.endpoint.includes('?') ? '&' : '?';
61
+ const data = await apiGet(`${route.endpoint}${sep}limit=50&offset=0`, { agentKey: agent.apiKey });
26
62
  state.messages = data.messages ?? [];
27
63
  renderList();
28
64
  } catch (err) {
29
- document.getElementById('list-rows').innerHTML =
30
- `<div class="empty">Failed to load: ${escapeHtml(err.message)}</div>`;
65
+ // Empty folder is a normal state; "no such folder" lands here
66
+ // too. Show a friendly empty message rather than a raw HTTP error.
67
+ const msg = String(err.message ?? err);
68
+ document.getElementById('list-rows').innerHTML = msg.includes('404')
69
+ ? `<div class="empty">${escapeHtml(folderTitle(folder))} is empty.</div>`
70
+ : `<div class="empty">Failed to load: ${escapeHtml(msg)}</div>`;
31
71
  }
32
72
  }
33
73
 
@@ -45,8 +85,10 @@ export function renderList() {
45
85
 
46
86
  // Client-side folder filtering for the folders the API doesn't
47
87
  // distinguish for us yet. Starred uses the IMAP \Flagged flag.
88
+ // Flags may come back as an array OR an object map ({Seen: true})
89
+ // depending on the IMAP path — always coerce before .includes().
48
90
  if (state.selectedFolder === 'starred') {
49
- filtered = filtered.filter(m => (m.flags ?? []).includes('\\Flagged'));
91
+ filtered = filtered.filter(m => flagsHas(m.flags, '\\Flagged'));
50
92
  }
51
93
 
52
94
  const hlTerm = filters?.subject || filters?.from || filters?.text || '';
@@ -70,8 +112,8 @@ export function renderList() {
70
112
  }
71
113
 
72
114
  root.innerHTML = filtered.map(m => {
73
- const unread = !(m.flags ?? []).includes('\\Seen');
74
- const starred = (m.flags ?? []).includes('\\Flagged');
115
+ const unread = !flagsHas(m.flags, '\\Seen');
116
+ const starred = flagsHas(m.flags, '\\Flagged');
75
117
  const fromAddr = m.from?.[0]?.address ?? '?';
76
118
  const fromName = m.from?.[0]?.name || fromAddr;
77
119
  const subject = m.subject ?? '(no subject)';
package/public/styles.css CHANGED
@@ -78,14 +78,27 @@ a { color: var(--accent-strong); }
78
78
  }
79
79
  .menu-btn:hover { background: var(--bg-hover); }
80
80
  .brand {
81
- display: flex; align-items: center; gap: 8px;
81
+ display: flex; align-items: center; gap: 10px;
82
82
  padding: 0 8px; min-width: 200px;
83
83
  }
84
84
  .brand-bow { font-size: 28px; line-height: 1; }
85
+ /* The brand bow PNG ships with transparent background — no rounded
86
+ crop, no fill. Sits flush against the topbar. */
87
+ .brand-logo {
88
+ width: 36px; height: 36px;
89
+ flex-shrink: 0;
90
+ display: block;
91
+ object-fit: contain;
92
+ }
85
93
  .brand-name {
86
94
  font: 500 22px/1 'Google Sans', sans-serif;
87
95
  color: var(--pink);
88
96
  }
97
+ .auth-card .brand-logo {
98
+ width: 32px; height: 32px;
99
+ vertical-align: middle;
100
+ display: inline-block;
101
+ }
89
102
 
90
103
  .search-container {
91
104
  flex: 1; max-width: 720px;
@@ -196,9 +209,14 @@ a { color: var(--accent-strong); }
196
209
  .avatar-sm { width: 24px; height: 24px; font-size: 11px; }
197
210
  .avatar-md { width: 40px; height: 40px; font-size: 16px; }
198
211
  .avatar-lg { width: 48px; height: 48px; font-size: 20px; }
199
- .avatar-host { background: #fce8e0; color: #cc785c; }
212
+ .avatar-host { background: #fce8e0; color: #d97757; }
200
213
  @media (prefers-color-scheme: dark) { .avatar-host { background: #2a1810; } }
201
214
  .avatar svg { width: 60%; height: 60%; }
215
+ .avatar-img {
216
+ width: 70%; height: 70%;
217
+ object-fit: contain;
218
+ display: block;
219
+ }
202
220
  .avatar-check {
203
221
  position: absolute; bottom: -2px; right: -2px;
204
222
  width: 14px; height: 14px; border-radius: 50%;
@@ -226,12 +244,66 @@ a { color: var(--accent-strong); }
226
244
  grid-template-columns: 256px 1fr;
227
245
  overflow: hidden;
228
246
  background: var(--bg-soft);
247
+ position: relative;
229
248
  }
249
+ .sidebar-backdrop {
250
+ display: none;
251
+ position: fixed; inset: 64px 0 0 0;
252
+ background: rgba(0,0,0,0.4);
253
+ z-index: 14;
254
+ }
255
+ /* ─── Mobile / narrow viewport ──────────────────────────────────── */
230
256
  @media (max-width: 800px) {
231
- .main { grid-template-columns: 72px 1fr; }
232
- .sidebar-label { display: none; }
233
- .compose-text { display: none; }
234
- .compose-btn { justify-content: center; padding: 0; width: 56px; }
257
+ .main { grid-template-columns: 1fr; }
258
+ .sidebar {
259
+ position: fixed; top: 64px; bottom: 0; left: 0;
260
+ width: 280px; max-width: 85vw;
261
+ background: var(--bg-soft);
262
+ z-index: 15;
263
+ transform: translateX(-100%);
264
+ transition: transform .22s ease;
265
+ box-shadow: 2px 0 16px rgba(0,0,0,0.1);
266
+ }
267
+ .main.sidebar-open .sidebar { transform: translateX(0); }
268
+ .main.sidebar-open .sidebar-backdrop { display: block; }
269
+ .content { border-radius: 0; margin: 0; }
270
+ .topbar { padding: 8px 8px; gap: 4px; }
271
+ .brand { min-width: auto; }
272
+ .brand-name { font-size: 18px; }
273
+ .search-container { max-width: none; }
274
+ .search-input { height: 40px; font-size: 14px; }
275
+ /* List rows lose the from column on narrow screens; the subject
276
+ gets full width with the sender folded into the preview. */
277
+ .list-row {
278
+ grid-template-columns: 24px 24px 1fr 70px;
279
+ height: 56px;
280
+ padding: 0 12px;
281
+ }
282
+ .list-row .from { display: none; }
283
+ .list-row .subject-cell {
284
+ flex-direction: column;
285
+ gap: 2px;
286
+ align-items: flex-start;
287
+ }
288
+ .list-row .subject { max-width: none; font-size: 14px; }
289
+ .list-row .preview { font-size: 13px; }
290
+ .list-row .preview::before { content: ''; }
291
+ .message-header { padding: 16px 16px 8px; }
292
+ .message-subject { font-size: 18px; }
293
+ .message-body { padding: 8px 16px 24px; max-width: none; }
294
+ .message-attachments { padding: 12px 16px; }
295
+ /* Compose modal goes full-screen on mobile rather than a tiny
296
+ bottom-right popup that nobody can type into. */
297
+ .compose-bg { padding: 0; align-items: stretch; justify-content: stretch; }
298
+ .compose-modal { width: 100%; max-height: 100vh; border-radius: 0; }
299
+ .compose-body textarea { min-height: 40vh; }
300
+ /* Hide non-essential top-bar buttons on narrow screens. */
301
+ .topbar-spacer { flex: 0; }
302
+ #refresh-btn { display: none; }
303
+ }
304
+ @media (min-width: 801px) {
305
+ /* Hamburger menu only matters on mobile; hide on desktop. */
306
+ .menu-btn { display: none; }
235
307
  }
236
308
 
237
309
  /* ─── Sidebar ──────────────────────────────────────────────────────── */