@imenam/mcp-messenger 1.0.17 → 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.
package/README.md CHANGED
@@ -377,7 +377,7 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
377
377
 
378
378
  ## Console web
379
379
 
380
- `http://127.0.0.1:3600/ui/` affiche, en lecture seule, **tous les échanges** de la flotte,
380
+ `http://127.0.0.1:3600/ui/` affiche **tous les échanges** de la flotte,
381
381
  organisés autour du fil — l'unité d'une correspondance, celle qui porte le sujet, les deux
382
382
  correspondants et l'état de lecture :
383
383
 
@@ -386,7 +386,8 @@ correspondants et l'état de lecture :
386
386
  chevron sur ses boîtes ; une boîte que son agent n'a pas nommée s'affiche à son adresse,
387
387
  la seule chose qui la distingue alors de ses sœurs. Cliquer l'agent filtre les fils sur
388
388
  **toutes ses sessions**, cliquer une boîte sur **cette seule session**. La colonne se
389
- replie sur les seuls avatars d'agents quand la place manque.
389
+ replie sur les seuls avatars d'agents quand la place manque, son bouton de repli restant
390
+ en place : c'est lui qui la rouvre.
390
391
 
391
392
  **Le classement se fait en deux temps : d'abord ce qui a servi, ensuite ce qui est
392
393
  vide ; puis, dans chacun des deux, la dernière activité, la plus récente en tête.** Une
@@ -439,7 +440,31 @@ navigateur de l'administrateur. Les routes `/admin` acceptent toujours le jeton
439
440
  `Authorization: Bearer` pour les scripts. Le formulaire est limité à 10 tentatives par
440
441
  quart d'heure et par IP.
441
442
 
442
- La console n'écrit jamais : elle ne peut ni envoyer, ni supprimer, ni modifier.
443
+ La console n'envoie ni ne modifie aucun message. Elle ne commande qu'une seule écriture,
444
+ et c'est une suppression : la **purge des boîtes inactives**, derrière l'icône de corbeille
445
+ de la barre du haut.
446
+
447
+ Une boîte est inactive quand plus rien ne l'a touchée depuis la date choisie : ni
448
+ connexion, ni message reçu ou envoyé. **Une boîte créée après cette date est épargnée,
449
+ même vide** — une session qui vient de démarrer et n'a pas encore parlé n'est pas une
450
+ boîte à l'abandon.
451
+
452
+ La purge se fait en deux temps, parce qu'elle ne se rattrape pas :
453
+
454
+ 1. **L'aperçu** liste les boîtes concernées, chacune avec son nom, son adresse, son nombre
455
+ de messages et sa dernière activité. Chaque ligne se décoche.
456
+ 2. **La confirmation** demande deux clics, et n'envoie que les adresses affichées. Le
457
+ serveur, lui, reconfronte chacune à la date avant de supprimer : une boîte redevenue
458
+ active entre l'aperçu et la confirmation fait échouer la purge entière, nommément.
459
+ Changer la date ou décocher une ligne annule une confirmation déjà donnée.
460
+
461
+ **Ce qui disparaît.** Un message a deux copies, une par correspondant : supprimer une
462
+ boîte n'emporte que sa moitié. Le message lui-même ne s'efface qu'une fois sa dernière
463
+ copie partie, autrement dit quand plus personne ne pourrait le lire ; un fil vidé de ses
464
+ messages s'en va avec. Ce que l'autre côté a reçu reste intact, expéditeur compris.
465
+
466
+ Le registre local des boîtes n'a pas à être nettoyé après une purge : une entrée qui
467
+ désigne une boîte disparue n'est jamais reprise, et s'oublie d'elle-même.
443
468
 
444
469
  ---
445
470
 
@@ -506,6 +531,8 @@ la console (cookie), qui elle n'ouvre rien d'autre.
506
531
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
507
532
  | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
508
533
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
534
+ | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
535
+ | `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`before`, `mailboxes`) — refusé si l'une d'elles est redevenue active |
509
536
  | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
510
537
 
511
538
  ---
@@ -525,8 +552,12 @@ la console (cookie), qui elle n'ouvre rien d'autre.
525
552
  dans les logs).
526
553
  - **Isolation des boîtes** garantie côté serveur : les identifiants de messages fournis par
527
554
  un agent sont systématiquement filtrés sur ses propres livraisons.
528
- - La console web n'insère jamais de HTML fourni par un agent (`textContent` uniquement) et
529
- la CSP interdit tout script tiers.
555
+ - La console web n'insère jamais de HTML fourni par un agent : le corps des messages est
556
+ rendu en markdown par construction de nœuds, jamais par assignation de balisage, et la
557
+ CSP interdit tout script tiers.
558
+ - La console ne peut ni écrire ni modifier un message. Sa seule commande destructrice est
559
+ la purge des boîtes inactives, réservée au jeton d'administration, confirmée deux fois et
560
+ revérifiée par le serveur avant exécution.
530
561
  - Derrière un reverse proxy TLS, exposer le serveur en `https://` et donner cette URL aux
531
562
  agents via `MESSENGER_SERVER_URL`.
532
563
 
@@ -567,7 +598,8 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
567
598
 
568
599
  ### Tests
569
600
 
570
- 207 tests couvrant : la reprise de la boîte d'une session interrompue (clé de session de
601
+ 231 tests couvrant : la purge des boîtes inactives commandée depuis la console (aperçu,
602
+ double confirmation, refus nominatif d'une boîte redevenue active), la reprise de la boîte d'une session interrompue (clé de session de
571
603
  l'hôte, boîte orpheline, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
572
604
  l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
573
605
  ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
@@ -87,6 +87,31 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
87
87
  stats(agentId: string): MailboxStats;
88
88
  /** Purge les livraisons restées en corbeille au-delà de `days`. Renvoie le nombre supprimé. */
89
89
  purgeTrash(days: number): number;
90
+ /**
91
+ * Boîtes dont plus rien ne bouge depuis `before`, avec ce que leur suppression coûterait.
92
+ *
93
+ * L'inactivité se mesure au plus récent de trois moments : la dernière connexion, la
94
+ * dernière livraison, et la création. Le dernier compte autant que les autres — une
95
+ * boîte allouée il y a une minute et qui n'a rien fait n'est pas une boîte à l'abandon,
96
+ * c'est une session qui n'a pas encore parlé.
97
+ */
98
+ staleMailboxes(before: string): (AgentDirectoryEntry & {
99
+ messages: number;
100
+ })[];
101
+ /**
102
+ * Supprime ces boîtes et tout ce qui n'appartenait qu'à elles.
103
+ *
104
+ * Un message a deux copies, une par correspondant : effacer une boîte n'efface donc que
105
+ * sa moitié. Le message lui-même ne disparaît qu'une fois sa dernière copie partie —
106
+ * autrement dit quand plus personne ne peut le lire. Ce que l'autre côté a reçu reste
107
+ * intact, avec l'adresse de l'expéditeur telle qu'elle a été livrée.
108
+ */
109
+ deleteMailboxes(ids: string[]): {
110
+ mailboxes: number;
111
+ deliveries: number;
112
+ messages: number;
113
+ threads: number;
114
+ };
90
115
  /** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
91
116
  listAllMessages(opts?: {
92
117
  search?: string;
@@ -581,7 +581,64 @@ export class MessageStore extends EventEmitter {
581
581
  this.dropOrphanMessages();
582
582
  return removed;
583
583
  }
584
- // --- Supervision (interface web, lecture seule) --------------------------
584
+ /**
585
+ * Boîtes dont plus rien ne bouge depuis `before`, avec ce que leur suppression coûterait.
586
+ *
587
+ * L'inactivité se mesure au plus récent de trois moments : la dernière connexion, la
588
+ * dernière livraison, et la création. Le dernier compte autant que les autres — une
589
+ * boîte allouée il y a une minute et qui n'a rien fait n'est pas une boîte à l'abandon,
590
+ * c'est une session qui n'a pas encore parlé.
591
+ */
592
+ staleMailboxes(before) {
593
+ const rows = this.db
594
+ .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
595
+ (SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
596
+ FROM agents a
597
+ WHERE MAX(${LAST_ACTIVITY}, a.created_at) < ?
598
+ ORDER BY MAX(${LAST_ACTIVITY}, a.created_at) ASC`)
599
+ .all(before);
600
+ return rows.map((row) => ({
601
+ ...row,
602
+ registered: Boolean(row.registered),
603
+ last_activity_at: row.last_activity_at || null,
604
+ }));
605
+ }
606
+ /**
607
+ * Supprime ces boîtes et tout ce qui n'appartenait qu'à elles.
608
+ *
609
+ * Un message a deux copies, une par correspondant : effacer une boîte n'efface donc que
610
+ * sa moitié. Le message lui-même ne disparaît qu'une fois sa dernière copie partie —
611
+ * autrement dit quand plus personne ne peut le lire. Ce que l'autre côté a reçu reste
612
+ * intact, avec l'adresse de l'expéditeur telle qu'elle a été livrée.
613
+ */
614
+ deleteMailboxes(ids) {
615
+ if (ids.length === 0)
616
+ return { mailboxes: 0, deliveries: 0, messages: 0, threads: 0 };
617
+ return this.db.transaction(() => {
618
+ const messagesBefore = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
619
+ const threadsBefore = this.db.prepare("SELECT COUNT(*) AS n FROM threads").get().n;
620
+ let mailboxes = 0;
621
+ let deliveries = 0;
622
+ const dropDeliveries = this.db.prepare("DELETE FROM deliveries WHERE agent_id = ?");
623
+ const dropFolders = this.db.prepare("DELETE FROM folders WHERE agent_id = ?");
624
+ const dropAgent = this.db.prepare("DELETE FROM agents WHERE id = ?");
625
+ for (const id of ids) {
626
+ deliveries += dropDeliveries.run(id).changes;
627
+ dropFolders.run(id);
628
+ mailboxes += dropAgent.run(id).changes;
629
+ }
630
+ this.dropOrphanMessages();
631
+ const messagesAfter = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
632
+ const threadsAfter = this.db.prepare("SELECT COUNT(*) AS n FROM threads").get().n;
633
+ return {
634
+ mailboxes,
635
+ deliveries,
636
+ messages: messagesBefore - messagesAfter,
637
+ threads: threadsBefore - threadsAfter,
638
+ };
639
+ })();
640
+ }
641
+ // --- Supervision (interface web, lecture seule sauf purge) ---------------
585
642
  /** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
586
643
  listAllMessages(opts = {}) {
587
644
  const limit = clamp(opts.limit ?? 50, 1, MAX_LIMIT);
@@ -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" />