@imenam/mcp-messenger 1.0.16 → 1.0.18

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.
@@ -7,6 +7,8 @@ import { removeOwnHandoffs } from "../cli/watch-handoff.js";
7
7
  import { newId } from "../core/ids.js";
8
8
  import { logger } from "../logger.js";
9
9
  import { APP_VERSION } from "../version.js";
10
+ import { HEARTBEAT_MS, claim, heartbeat, orphanEntries, projectFingerprint, purge, releaseOwn, sessionKeyDigest, } from "./mailbox-registry.js";
11
+ import { setResumeState } from "./resume-state.js";
10
12
  import { TOOLS, callTool } from "./tools.js";
11
13
  /**
12
14
  * Ce que l'hôte reçoit à la poignée de main, avant le moindre appel d'outil.
@@ -29,6 +31,11 @@ Who you are, and who the others are — read this before writing to anyone:
29
31
  - One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
30
32
  - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
31
33
 
34
+ Resuming earlier work:
35
+ - A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
36
+ - When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
37
+ - If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
38
+
32
39
  Writing:
33
40
  - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
34
41
  - Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
@@ -55,17 +62,61 @@ export function createMcpServer(client) {
55
62
  /**
56
63
  * Donne son adresse à la session.
57
64
  *
58
- * Avec `MESSENGER_AGENT_ID`, l'adresse est fixe et partagée par toutes les sessions qui
59
- * utilisent cette configuration. Sans elle — le cas normal — une boîte neuve est allouée
60
- * pour cette session : deux agents lancés dans le même projet ne se marchent pas dessus.
61
- * L'identifiant est opaque : c'est une adresse à transmettre, pas un rôle à deviner.
65
+ * Trois chemins, du plus certain au plus approximatif :
66
+ *
67
+ * 1. `MESSENGER_AGENT_ID` fixe l'adresse, partagée par toutes les sessions de cette
68
+ * configuration. Rien à chercher.
69
+ * 2. Le registre local rend à une session reprise la boîte qu'elle tenait : identique si
70
+ * l'hôte a redonné le même identifiant de session, sinon la seule boîte du projet
71
+ * qu'aucune session vivante ne tient plus et qui a réellement servi.
72
+ * 3. À défaut, une boîte neuve, comme avant. Les boîtes orphelines non reprises sont
73
+ * gardées de côté : `whoami` les propose, l'agent en rattache une s'il reconnaît la
74
+ * sienne dans son contexte.
75
+ *
76
+ * Une boîte tenue par une session vivante n'est jamais reprise : c'est ce qui distingue
77
+ * ce mécanisme d'une adresse partagée, et ce qui protège deux sessions ouvertes en
78
+ * parallèle sur le même projet.
62
79
  */
63
- export async function resolveIdentity(client, config) {
80
+ export async function resolveIdentity(client, config, options = {}) {
81
+ const keySource = config.sessionKey ? config.sessionKeySource : null;
64
82
  if (config.agentId) {
65
83
  client.setAgentId(config.agentId, { own: true });
66
84
  await client.register();
67
- return config.agentId;
85
+ const identity = { mailbox: config.agentId, resumed: null, candidates: [] };
86
+ setResumeState(client, { resumed: null, candidates: [], keySource });
87
+ return identity;
88
+ }
89
+ const fingerprint = projectFingerprint({
90
+ cwd: options.cwd ?? process.cwd(),
91
+ agentName: config.agentName,
92
+ serverUrl: config.serverUrl,
93
+ });
94
+ const sessionKey = config.sessionKey ? sessionKeyDigest(config.sessionKey) : null;
95
+ purge();
96
+ const found = config.resume === "off" ? [] : await inspectOrphans(config, orphanEntries(fingerprint));
97
+ const exact = sessionKey ? found.find((c) => c.entry.session_key === sessionKey) : undefined;
98
+ // Une seule boîte laissée par une session arrêtée, et elle a servi : l'ambiguïté
99
+ // n'existe pas. Plusieurs, ou une boîte vide, et c'est à l'agent de choisir.
100
+ const sole = found.length === 1 && found[0].candidate.messages > 0 ? found[0] : undefined;
101
+ const taken = config.resume === "auto" ? (exact ?? sole) : exact;
102
+ if (taken) {
103
+ client.setAgentId(taken.candidate.mailbox, { own: true });
104
+ await client.register();
105
+ claim({ mailbox: taken.candidate.mailbox, fingerprint, sessionKey });
106
+ const resumed = taken === exact ? "session-key" : "orphan";
107
+ const candidates = found.filter((c) => c !== taken).map((c) => c.candidate);
108
+ setResumeState(client, { resumed, candidates, keySource });
109
+ return { mailbox: taken.candidate.mailbox, resumed, candidates };
68
110
  }
111
+ const mailbox = await allocateMailbox(client);
112
+ claim({ mailbox, fingerprint, sessionKey });
113
+ // Une boîte vide n'apprend rien à personne : seules celles qui ont du courrier valent
114
+ // la peine d'être proposées.
115
+ const candidates = found.map((c) => c.candidate).filter((c) => c.messages > 0);
116
+ setResumeState(client, { resumed: null, candidates, keySource });
117
+ return { mailbox, resumed: null, candidates };
118
+ }
119
+ async function allocateMailbox(client) {
69
120
  // `fresh` fait échouer l'enregistrement si l'adresse existe déjà : la collision, même
70
121
  // improbable sur 64 bits d'aléa, ne peut pas faire atterrir deux sessions au même endroit.
71
122
  for (let attempt = 0; attempt < 5; attempt += 1) {
@@ -83,6 +134,57 @@ export async function resolveIdentity(client, config) {
83
134
  }
84
135
  throw new Error("could not allocate a mailbox: too many id collisions");
85
136
  }
137
+ /**
138
+ * Ce que le journal dit du démarrage.
139
+ *
140
+ * C'est le seul endroit d'où l'on peut vérifier que l'hôte transmet bien un identifiant
141
+ * de session, et lequel : sans cette ligne, une reprise qui n'a pas lieu ne s'explique pas.
142
+ */
143
+ function describeIdentity(identity, config) {
144
+ const key = config.sessionKeySource ? `session key from ${config.sessionKeySource}` : "no host session key";
145
+ const how = identity.resumed === "session-key"
146
+ ? "resumed from the previous session (same host session id)"
147
+ : identity.resumed === "orphan"
148
+ ? "resumed the only mailbox left by a stopped session of this project"
149
+ : "new mailbox";
150
+ const waiting = identity.candidates.length > 0 ? `, ${identity.candidates.length} resumable mailbox(es)` : "";
151
+ return ` — ${how} (${key})${waiting}`;
152
+ }
153
+ /** Nombre de boîtes orphelines examinées : au-delà, le démarrage coûterait plus qu'il ne rend. */
154
+ const MAX_CANDIDATES = 5;
155
+ /**
156
+ * Confronte les entrées du registre au serveur : une boîte peut avoir disparu (base
157
+ * réinitialisée), et une boîte vide n'a rien à reprendre. Un serveur injoignable ne fait
158
+ * pas échouer le démarrage, il rend simplement la reprise impossible pour cette fois.
159
+ */
160
+ async function inspectOrphans(config, entries) {
161
+ // Client jetable : la session n'a pas encore d'adresse, et celle-ci ne doit pas bouger
162
+ // au gré de l'inspection.
163
+ const probe = new MessengerClient(config);
164
+ const found = [];
165
+ for (const entry of entries.slice(0, MAX_CANDIDATES)) {
166
+ probe.setAgentId(entry.mailbox);
167
+ try {
168
+ const agent = await probe.getAgent(entry.mailbox);
169
+ const stats = await probe.stats();
170
+ found.push({
171
+ entry,
172
+ candidate: {
173
+ mailbox: entry.mailbox,
174
+ agent_name: agent.agent_name,
175
+ mailbox_name: agent.display_name,
176
+ messages: stats.folders.reduce((total, folder) => total + folder.total, 0),
177
+ unread: stats.unread_total,
178
+ last_activity: agent.last_seen_at,
179
+ },
180
+ });
181
+ }
182
+ catch (error) {
183
+ logger.info(`mailbox ${entry.mailbox} cannot be resumed: ${error instanceof Error ? error.message : error}`);
184
+ }
185
+ }
186
+ return found;
187
+ }
86
188
  /** Point d'entrée du mode MCP (mode par défaut, sans argument). */
87
189
  export async function runMcpMode() {
88
190
  const config = loadAgentConfig();
@@ -90,18 +192,27 @@ export async function runMcpMode() {
90
192
  // Un serveur momentanément injoignable ne doit pas empêcher la session MCP de démarrer :
91
193
  // l'agent verra l'erreur au premier outil appelé, et pourra réessayer.
92
194
  try {
93
- const agentId = await resolveIdentity(client, config);
94
- logger.info(`mailbox \`${agentId}\` ready on ${config.serverUrl}`);
195
+ const identity = await resolveIdentity(client, config);
196
+ logger.info(`mailbox \`${identity.mailbox}\` ready on ${config.serverUrl}${describeIdentity(identity, config)}`);
95
197
  }
96
198
  catch (error) {
97
199
  logger.warn(`could not reach ${config.serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
98
200
  }
201
+ // Tant que ce battement continue, la boîte de cette session n'est reprise par personne.
202
+ // `unref` : c'est une trace, pas une raison de garder le processus en vie.
203
+ setInterval(heartbeat, HEARTBEAT_MS).unref();
99
204
  // Le relais vers le veilleur porte le jeton de la session : il ne survit pas à la
100
205
  // session. Un arrêt brutal le laisse en place, la purge par ancienneté le rattrape.
101
- process.on("exit", removeOwnHandoffs);
206
+ // L'entrée du registre suit le même sort, à ceci près qu'elle est close et non effacée :
207
+ // c'est elle qui rendra sa boîte à la session suivante.
208
+ process.on("exit", () => {
209
+ removeOwnHandoffs();
210
+ releaseOwn();
211
+ });
102
212
  for (const signal of ["SIGINT", "SIGTERM"]) {
103
213
  process.on(signal, () => {
104
214
  removeOwnHandoffs();
215
+ releaseOwn();
105
216
  process.exit(0);
106
217
  });
107
218
  }
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { writeHandoff } from "../cli/watch-handoff.js";
3
3
  import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
4
+ import { rebind } from "./mailbox-registry.js";
5
+ import { forgetCandidate, getResumeState } from "./resume-state.js";
4
6
  /** Nom publié : l'agent recopie la commande, il n'a pas à connaître de chemin local. */
5
7
  const PACKAGE_NAME = "@imenam/mcp-messenger";
6
8
  const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
@@ -335,6 +337,7 @@ export async function callTool(client, name, rawArgs) {
335
337
  if (client.isAttachedElsewhere) {
336
338
  lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
337
339
  }
340
+ lines.push(...resumeLines(client));
338
341
  lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
339
342
  // Sans veilleur, un message arrive en boîte sans que personne n'en soit averti.
340
343
  // Le dire ici est le seul moyen pour l'agent de s'en apercevoir.
@@ -350,12 +353,17 @@ export async function callTool(client, name, rawArgs) {
350
353
  if (!own)
351
354
  throw new Error("this session has no mailbox of its own to return to");
352
355
  client.setAgentId(own);
356
+ rebind(own);
353
357
  return `Back to this session's own mailbox: ${own}`;
354
358
  }
355
359
  // Vérifie que la boîte existe avant de basculer : une faute de frappe créerait
356
360
  // silencieusement une boîte vide, et l'agent croirait avoir repris le travail.
357
361
  const target = await client.getAgent(input.mailbox_id);
358
362
  client.setAgentId(target.id);
363
+ // La session travaille désormais cette boîte : c'est elle que sa prochaine reprise
364
+ // doit retrouver, et elle n'a plus à être proposée.
365
+ rebind(target.id);
366
+ forgetCandidate(client, target.id);
359
367
  const attachedStats = await client.stats();
360
368
  return [
361
369
  `Attached to mailbox ${target.id}${formatAgentNames(target) ? ` (${formatAgentNames(target)})` : ""}.`,
@@ -678,6 +686,35 @@ async function renameThread(client, threadId, name, description) {
678
686
  .filter(Boolean)
679
687
  .join("\n");
680
688
  }
689
+ /**
690
+ * Ce que `whoami` dit des sessions précédentes.
691
+ *
692
+ * Deux choses, et seulement quand elles ont eu lieu : que cette boîte vient d'une session
693
+ * arrêtée — l'agent doit savoir d'où sortent des fils qu'il n'a pas ouverts — et quelles
694
+ * boîtes du projet attendent encore quelqu'un.
695
+ */
696
+ function resumeLines(client) {
697
+ const state = getResumeState(client);
698
+ if (!state)
699
+ return [];
700
+ const lines = [];
701
+ if (state.resumed === "session-key") {
702
+ lines.push("Resumed: this is the mailbox of the session you are continuing (matched on the host session id).");
703
+ }
704
+ else if (state.resumed === "orphan") {
705
+ lines.push("Resumed: this mailbox was left by a stopped session of this project, and no live session held it.", " ↳ its threads and unread messages are not yours from this conversation; read before you answer.");
706
+ }
707
+ if (state.candidates.length > 0) {
708
+ lines.push("Mailboxes left by a stopped session of this project:");
709
+ for (const candidate of state.candidates) {
710
+ const name = candidate.mailbox_name ?? "unnamed mailbox";
711
+ const seen = candidate.last_activity ? ` · last activity ${candidate.last_activity}` : "";
712
+ lines.push(` ${candidate.mailbox} — ${name} · ${candidate.messages} message(s), ${candidate.unread} unread${seen}`);
713
+ }
714
+ lines.push(" ↳ if you are resuming that work, call attach_to_mailbox with its address.");
715
+ }
716
+ return lines;
717
+ }
681
718
  /** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
682
719
  function formatThreadRef(title, threadId) {
683
720
  return title ? `${title} (${threadId})` : threadId;
@@ -89,6 +89,24 @@ const adminThreadsSchema = z.object({
89
89
  limit: z.coerce.number().int().min(1).max(100).optional(),
90
90
  offset: z.coerce.number().int().min(0).optional(),
91
91
  });
92
+ /**
93
+ * Date de coupure d'une purge.
94
+ *
95
+ * Un jour (`2026-08-01`) autant qu'un instant (`2026-08-01T09:00:00.000Z`) : la console
96
+ * envoie ce que le champ de date du navigateur produit, un jour nu, qui est déjà un
97
+ * horodatage ISO valide et se compare tel quel aux dates stockées.
98
+ */
99
+ const isoDate = z
100
+ .string()
101
+ .trim()
102
+ .regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?$/, "expected a date such as 2026-08-01")
103
+ .refine((value) => !Number.isNaN(Date.parse(value)), "not a real date");
104
+ const staleSchema = z.object({ before: isoDate });
105
+ const purgeSchema = z.object({
106
+ before: isoDate,
107
+ /** Les adresses vues dans l'aperçu : on ne supprime jamais autre chose que ce qui a été montré. */
108
+ mailboxes: z.array(z.string().trim().min(1)).min(1).max(500),
109
+ });
92
110
  const adminListSchema = z.object({
93
111
  search: z.string().trim().min(1).optional(),
94
112
  agent: z.string().trim().min(1).optional(),
@@ -157,7 +175,7 @@ export function createApp({ store, config }) {
157
175
  app.get("/", (_req, res) => {
158
176
  res.redirect("/ui/");
159
177
  });
160
- // --- Supervision (lecture seule) -----------------------------------------
178
+ // --- Supervision (lecture, sauf la purge des boîtes inactives) -----------
161
179
  // Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
162
180
  // console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
163
181
  const admin = express.Router();
@@ -197,6 +215,35 @@ export function createApp({ store, config }) {
197
215
  throw new MessengerError(`thread not found: ${threadId}`, 404);
198
216
  res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
199
217
  });
218
+ /**
219
+ * Boîtes sans activité depuis une date : ce que la purge emporterait.
220
+ *
221
+ * Toujours consultée avant de supprimer — la console montre la liste, et c'est sur cette
222
+ * liste que porte la suppression. Une purge ne se décide pas sur un compte.
223
+ */
224
+ admin.get("/mailboxes/stale", (req, res) => {
225
+ const { before } = staleSchema.parse(req.query);
226
+ res.json({ before, mailboxes: store.staleMailboxes(before) });
227
+ });
228
+ /**
229
+ * La seule route de la console qui écrit, et elle détruit.
230
+ *
231
+ * Deux garde-fous, parce qu'une purge ne se rattrape pas : la date est redemandée avec
232
+ * les adresses, et le serveur refuse toute adresse qui ne serait pas, au moment où il
233
+ * répond, sans activité depuis cette date. Une boîte redevenue vivante entre l'aperçu
234
+ * et la confirmation est donc épargnée, et le refus est nominatif.
235
+ */
236
+ admin.post("/mailboxes/purge", (req, res) => {
237
+ const input = purgeSchema.parse(req.body ?? {});
238
+ const stale = new Set(store.staleMailboxes(input.before).map((mailbox) => mailbox.id));
239
+ const active = input.mailboxes.filter((id) => !stale.has(id));
240
+ if (active.length > 0) {
241
+ throw new MessengerError(`these mailboxes are no longer inactive since ${input.before}, nothing was deleted: ${active.join(", ")}`, 409);
242
+ }
243
+ const removed = store.deleteMailboxes(input.mailboxes);
244
+ logger.warn(`admin purge: ${removed.mailboxes} mailbox(es) deleted, inactive since ${input.before}`);
245
+ res.json({ before: input.before, ...removed });
246
+ });
200
247
  app.use("/admin", admin);
201
248
  // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
202
249
  // contrôle par route (identité de l'agent) vient ensuite.
@@ -1,4 +1,7 @@
1
- // Console de supervision : lecture seule sur les routes /admin du serveur.
1
+ // Console de supervision : les routes /admin du serveur, en lecture — à une exception
2
+ // près, la purge des boîtes inactives, seule commande de cette page qui écrit, et qui
3
+ // détruit. Elle est donc tenue à part : un aperçu, puis une confirmation, et rien ne part
4
+ // que le serveur n'ait redemandé sa date.
2
5
  //
3
6
  // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
4
7
  // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
@@ -77,6 +80,14 @@ const el = {
77
80
  threads: document.getElementById("threads"),
78
81
  reader: document.getElementById("reader"),
79
82
  sheet: document.getElementById("sheet"),
83
+ purge: document.getElementById("purge"),
84
+ purgeOpen: document.getElementById("purge-open"),
85
+ purgeClose: document.getElementById("purge-close"),
86
+ purgeCancel: document.getElementById("purge-cancel"),
87
+ purgeBefore: document.getElementById("purge-before"),
88
+ purgePreview: document.getElementById("purge-preview"),
89
+ purgeResult: document.getElementById("purge-result"),
90
+ purgeConfirm: document.getElementById("purge-confirm"),
80
91
  };
81
92
 
82
93
  // --- Fabrique de noeuds ----------------------------------------------------
@@ -235,6 +246,12 @@ function formatShort(iso) {
235
246
  return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
236
247
  }
237
248
 
249
+ /** Jour seul, sans heure : une purge se raisonne en jours, pas en minutes. */
250
+ function formatDay(iso) {
251
+ const date = new Date(iso);
252
+ return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleDateString();
253
+ }
254
+
238
255
  function plural(count, singular, pluralForm) {
239
256
  return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
240
257
  }
@@ -258,6 +275,23 @@ async function api(path, params) {
258
275
  return response.json();
259
276
  }
260
277
 
278
+ /** Envoi d'une action à la console. La seule qui existe supprime : elle passe par ici. */
279
+ async function post(path, body) {
280
+ const response = await fetch(path, {
281
+ method: "POST",
282
+ headers: { "content-type": "application/json", accept: "application/json" },
283
+ credentials: "same-origin",
284
+ body: JSON.stringify(body),
285
+ });
286
+ if (response.status === 401) {
287
+ setLoggedIn(false);
288
+ throw new Error("unauthorized");
289
+ }
290
+ const payload = await response.json().catch(() => ({}));
291
+ if (!response.ok) throw new Error(payload.error || response.statusText);
292
+ return payload;
293
+ }
294
+
261
295
  function setLoggedIn(loggedIn) {
262
296
  el.login.hidden = loggedIn;
263
297
  el.console.hidden = !loggedIn;
@@ -906,14 +940,160 @@ async function refresh(options) {
906
940
  }
907
941
  }
908
942
 
943
+ // --- Purge des boîtes inactives --------------------------------------------
944
+
945
+ /**
946
+ * Ce que la fenêtre de purge a sous la main : la date de l'aperçu, les boîtes que le
947
+ * serveur a désignées, celles qui restent cochées, et si la suppression a déjà été
948
+ * demandée une fois. Le second clic est ce qui la déclenche.
949
+ */
950
+ const purge = { before: null, mailboxes: [], selected: new Set(), armed: false };
951
+
952
+ function openPurge() {
953
+ const month = new Date(Date.now() - 30 * 86_400_000);
954
+ el.purgeBefore.value = month.toISOString().slice(0, 10);
955
+ el.purgeBefore.max = new Date().toISOString().slice(0, 10);
956
+ resetPurge();
957
+ el.purge.showModal();
958
+ }
959
+
960
+ /** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
961
+ function resetPurge() {
962
+ purge.before = null;
963
+ purge.mailboxes = [];
964
+ purge.selected = new Set();
965
+ purge.armed = false;
966
+ el.purgeResult.replaceChildren();
967
+ el.purgeConfirm.disabled = true;
968
+ el.purgeConfirm.textContent = "Supprimer";
969
+ }
970
+
971
+ async function previewPurge() {
972
+ const before = el.purgeBefore.value;
973
+ if (!before) return;
974
+ resetPurge();
975
+ try {
976
+ const { mailboxes } = await api("/admin/mailboxes/stale", { before });
977
+ purge.before = before;
978
+ purge.mailboxes = mailboxes;
979
+ purge.selected = new Set(mailboxes.map((mailbox) => mailbox.id));
980
+ renderPurgePreview();
981
+ } catch (error) {
982
+ el.purgeResult.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
983
+ }
984
+ }
985
+
986
+ function renderPurgePreview() {
987
+ const container = document.createDocumentFragment();
988
+
989
+ if (purge.mailboxes.length === 0) {
990
+ container.appendChild(node("p", "purge-empty", `Aucune boîte sans activité depuis le ${formatDay(purge.before)}.`));
991
+ el.purgeResult.replaceChildren(container);
992
+ return;
993
+ }
994
+
995
+ container.appendChild(
996
+ node("p", "purge-count", `${plural(purge.mailboxes.length, "boîte")} sans activité depuis le ${formatDay(purge.before)}.`)
997
+ );
998
+
999
+ const list = node("ul", "purge-list");
1000
+ for (const mailbox of purge.mailboxes) {
1001
+ const item = node("li", "purge-item");
1002
+ const label = node("label", "purge-line");
1003
+
1004
+ const box = document.createElement("input");
1005
+ box.type = "checkbox";
1006
+ box.checked = true;
1007
+ box.addEventListener("change", () => {
1008
+ if (box.checked) purge.selected.add(mailbox.id);
1009
+ else purge.selected.delete(mailbox.id);
1010
+ // Décocher change ce qui sera supprimé : la confirmation déjà armée ne vaut plus.
1011
+ purge.armed = false;
1012
+ updatePurgeButton();
1013
+ });
1014
+ label.appendChild(box);
1015
+
1016
+ const text = node("span", "purge-text");
1017
+ text.appendChild(node("span", "purge-name", mailbox.display_name || mailbox.agent_name || "boîte sans nom"));
1018
+ text.appendChild(node("span", "mono muted", mailbox.id));
1019
+ const activity = mailbox.last_activity_at ? `dernière activité ${formatDay(mailbox.last_activity_at)}` : "jamais utilisée";
1020
+ text.appendChild(node("span", "purge-meta", `${plural(mailbox.messages, "message")} · ${activity}`));
1021
+ label.appendChild(text);
1022
+
1023
+ item.appendChild(label);
1024
+ list.appendChild(item);
1025
+ }
1026
+ container.appendChild(list);
1027
+
1028
+ el.purgeResult.replaceChildren(container);
1029
+ updatePurgeButton();
1030
+ }
1031
+
1032
+ function updatePurgeButton() {
1033
+ const count = purge.selected.size;
1034
+ el.purgeConfirm.disabled = count === 0;
1035
+ el.purgeConfirm.textContent = purge.armed
1036
+ ? `Confirmer : supprimer ${plural(count, "boîte")}`
1037
+ : `Supprimer ${plural(count, "boîte")}`;
1038
+ }
1039
+
1040
+ async function confirmPurge() {
1041
+ if (purge.selected.size === 0 || !purge.before) return;
1042
+
1043
+ // Premier clic : la suppression est armée, pas faite. Rien ne part sans un second clic.
1044
+ if (!purge.armed) {
1045
+ purge.armed = true;
1046
+ updatePurgeButton();
1047
+ return;
1048
+ }
1049
+
1050
+ el.purgeConfirm.disabled = true;
1051
+ try {
1052
+ const removed = await post("/admin/mailboxes/purge", {
1053
+ before: purge.before,
1054
+ mailboxes: [...purge.selected],
1055
+ });
1056
+ resetPurge();
1057
+ el.purgeResult.replaceChildren(
1058
+ node(
1059
+ "p",
1060
+ "purge-done",
1061
+ `${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
1062
+ )
1063
+ );
1064
+ // La console montre encore ce qui n'existe plus : elle se relit entièrement.
1065
+ state.threadId = null;
1066
+ state.messageId = null;
1067
+ el.sheet.hidden = true;
1068
+ el.console.classList.remove("has-sheet");
1069
+ await refresh();
1070
+ } catch (error) {
1071
+ purge.armed = false;
1072
+ updatePurgeButton();
1073
+ el.purgeResult.appendChild(node("p", "purge-error", `Erreur : ${error.message}`));
1074
+ }
1075
+ }
1076
+
909
1077
  // --- Écouteurs -------------------------------------------------------------
910
1078
 
1079
+ el.purgeOpen.addEventListener("click", openPurge);
1080
+ el.purgeClose.addEventListener("click", () => el.purge.close());
1081
+ el.purgeCancel.addEventListener("click", () => el.purge.close());
1082
+ el.purgeBefore.addEventListener("change", resetPurge);
1083
+ el.purgePreview.addEventListener("click", previewPurge);
1084
+ el.purgeConfirm.addEventListener("click", confirmPurge);
1085
+
911
1086
  el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
912
1087
 
1088
+ // Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
1089
+ // rouvrir. Seul ce qu'il annonce change.
913
1090
  el.collapse.addEventListener("click", () => {
914
1091
  state.collapsed = !state.collapsed;
915
1092
  el.console.classList.toggle("is-collapsed", state.collapsed);
1093
+ const label = state.collapsed ? "Déplier la liste des agents" : "Replier la liste des agents";
916
1094
  el.collapse.title = state.collapsed ? "Déplier" : "Replier";
1095
+ el.collapse.setAttribute("aria-label", label);
1096
+ el.collapse.setAttribute("aria-expanded", String(!state.collapsed));
917
1097
  });
918
1098
 
919
1099
  el.chips.addEventListener("click", (event) => {
@@ -42,10 +42,57 @@
42
42
  <path d="M3 21v-5h5" />
43
43
  </svg>
44
44
  </button>
45
+ <button type="button" id="purge-open" class="btn btn--icon" title="Purger les boîtes inactives" aria-label="Purger les boîtes inactives">
46
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
47
+ <path d="M3 6h18" />
48
+ <path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
49
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
50
+ </svg>
51
+ </button>
45
52
  <button type="button" id="logout" class="btn btn--link">Se déconnecter</button>
46
53
  </div>
47
54
  </header>
48
55
 
56
+ <!--
57
+ Purge des boîtes inactives : la seule action de la console qui écrit, et elle
58
+ détruit. Elle se fait donc en deux temps, l'aperçu puis la confirmation, et ne porte
59
+ jamais que sur les boîtes affichées.
60
+ -->
61
+ <dialog class="modal" id="purge">
62
+ <div class="modal-card">
63
+ <div class="modal-head">
64
+ <h2>Purger les boîtes inactives</h2>
65
+ <button type="button" class="btn btn--icon btn--ghost" id="purge-close" aria-label="Fermer">
66
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
67
+ <path d="M18 6 6 18" />
68
+ <path d="m6 6 12 12" />
69
+ </svg>
70
+ </button>
71
+ </div>
72
+ <p class="modal-sub">
73
+ Une boîte est inactive quand plus rien ne l'a touchée depuis la date choisie : ni connexion, ni message reçu
74
+ ou envoyé. Une boîte créée après cette date est laissée tranquille, même vide. La suppression est définitive
75
+ et emporte les messages que plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste
76
+ intact.
77
+ </p>
78
+
79
+ <div class="purge-controls">
80
+ <label class="field">
81
+ <span class="field-label">Sans activité depuis le</span>
82
+ <span class="field-input"><input type="date" id="purge-before" /></span>
83
+ </label>
84
+ <button type="button" class="btn" id="purge-preview">Prévisualiser</button>
85
+ </div>
86
+
87
+ <div class="purge-result" id="purge-result"></div>
88
+
89
+ <div class="modal-foot">
90
+ <button type="button" class="btn btn--link" id="purge-cancel">Fermer</button>
91
+ <button type="button" class="btn btn--danger" id="purge-confirm" disabled>Supprimer</button>
92
+ </div>
93
+ </div>
94
+ </dialog>
95
+
49
96
  <!-- Écran de connexion : une carte seule, sans les panneaux vides derrière elle. -->
50
97
  <main class="login" id="login" hidden>
51
98
  <form class="card" id="login-form">
@@ -85,7 +132,7 @@
85
132
  <aside class="agents" id="agents-pane">
86
133
  <div class="agents-head">
87
134
  <span class="section-label">Agents</span>
88
- <button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents">
135
+ <button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents" aria-expanded="true">
89
136
  <svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
90
137
  <rect x="3" y="3" width="18" height="18" rx="2" />
91
138
  <path d="M9 3v18" />