@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 +38 -6
- package/dist/src/core/store.d.ts +25 -0
- package/dist/src/core/store.js +58 -1
- package/dist/src/server/app.js +48 -1
- package/dist/src/server/ui/app.js +181 -1
- package/dist/src/server/ui/index.html +48 -1
- package/dist/src/server/ui/styles.css +1560 -1409
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
|
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'
|
|
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
|
|
529
|
-
|
|
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
|
-
|
|
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,
|
package/dist/src/core/store.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/core/store.js
CHANGED
|
@@ -581,7 +581,64 @@ export class MessageStore extends EventEmitter {
|
|
|
581
581
|
this.dropOrphanMessages();
|
|
582
582
|
return removed;
|
|
583
583
|
}
|
|
584
|
-
|
|
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);
|
package/dist/src/server/app.js
CHANGED
|
@@ -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
|
|
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 :
|
|
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" />
|