@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 +66 -16
- package/package.json +1 -1
- package/public/branding/agenticmail-logo.png +0 -0
- package/public/branding/claude-mark.svg +2 -0
- package/public/index.html +6 -5
- package/public/js/app.js +23 -2
- package/public/js/avatar.js +10 -13
- package/public/js/list-view.js +52 -10
- package/public/styles.css +78 -6
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
|
|
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
|
-
|
|
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
|
-
|
|
4856
|
-
|
|
4905
|
+
join3(here, "..", "public"),
|
|
4906
|
+
join3(here, "public")
|
|
4857
4907
|
];
|
|
4858
|
-
for (const c of candidates) if (
|
|
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(
|
|
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
|
|
4942
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
4893
4943
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4894
|
-
import { dirname as dirname3, join as
|
|
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(
|
|
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
|
Binary file
|
|
@@ -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"
|
|
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><
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
}
|
package/public/js/avatar.js
CHANGED
|
@@ -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
|
|
5
|
-
// mark
|
|
6
|
-
//
|
|
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"
|
|
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 ?? '');
|
package/public/js/list-view.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
74
|
-
const starred = (m.flags
|
|
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:
|
|
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: #
|
|
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:
|
|
232
|
-
.sidebar
|
|
233
|
-
|
|
234
|
-
|
|
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 ──────────────────────────────────────────────────────── */
|