@imenam/mcp-messenger 1.0.29 → 1.0.31
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 +764 -707
- package/dist/src/cli/watch.d.ts +3 -1
- package/dist/src/cli/watch.js +12 -2
- package/dist/src/client/messenger-client.d.ts +21 -1
- package/dist/src/client/messenger-client.js +31 -9
- package/dist/src/core/attachments.d.ts +16 -0
- package/dist/src/core/attachments.js +28 -0
- package/dist/src/core/store.d.ts +34 -3
- package/dist/src/core/store.js +404 -216
- package/dist/src/core/types.d.ts +41 -0
- package/dist/src/mcp/start.js +25 -22
- package/dist/src/mcp/tools.js +186 -9
- package/dist/src/server/app.js +89 -12
- package/dist/src/server/ui/app.js +867 -7
- package/dist/src/server/ui/index.html +59 -0
- package/dist/src/server/ui/styles.css +751 -20
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +16 -2
- package/package.json +2 -2
package/dist/src/core/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "./attachments.js";
|
|
5
6
|
import { newId } from "./ids.js";
|
|
6
7
|
import { DELIVERY_ROLES, SYSTEM_FOLDERS, } from "./types.js";
|
|
7
8
|
/** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
|
|
@@ -18,14 +19,18 @@ const MAX_LIMIT = 100;
|
|
|
18
19
|
const FOLDER_NAME_RE = /^[a-z0-9][a-z0-9 _-]{0,39}$/;
|
|
19
20
|
const THREAD_TITLE_MAX = 200;
|
|
20
21
|
const THREAD_DESCRIPTION_MAX = 2000;
|
|
22
|
+
const FILENAME_MAX = 255;
|
|
23
|
+
const MIME_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
24
|
+
const DEFAULT_MIME_TYPE = "application/octet-stream";
|
|
25
|
+
const LINK_NOTE_MAX = 500;
|
|
21
26
|
/**
|
|
22
27
|
* Dernière activité d'une boîte : sa dernière connexion, ou le dernier message qui l'a
|
|
23
28
|
* touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
|
|
24
29
|
* seul la ferait paraître abandonnée.
|
|
25
30
|
*/
|
|
26
|
-
const LAST_ACTIVITY = `MAX(
|
|
27
|
-
COALESCE(a.last_seen_at, ''),
|
|
28
|
-
COALESCE((SELECT MAX(d.created_at) FROM deliveries d WHERE d.agent_id = a.id), '')
|
|
31
|
+
const LAST_ACTIVITY = `MAX(
|
|
32
|
+
COALESCE(a.last_seen_at, ''),
|
|
33
|
+
COALESCE((SELECT MAX(d.created_at) FROM deliveries d WHERE d.agent_id = a.id), '')
|
|
29
34
|
)`;
|
|
30
35
|
/** Colonnes du fil jointes à tout message : le titre voyage avec chaque livraison. */
|
|
31
36
|
const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
|
|
@@ -38,10 +43,10 @@ const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
|
|
|
38
43
|
* pose. Un message n'ayant qu'une copie d'expéditeur (destinataire jamais enregistré) n'a
|
|
39
44
|
* pas de ligne ici : `read_at` reste alors nul, donc non lu.
|
|
40
45
|
*/
|
|
41
|
-
const ADMIN_DELIVERY = `'to' AS role,
|
|
42
|
-
(SELECT dr.read_at FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
|
|
43
|
-
ORDER BY dr.agent_id LIMIT 1) AS read_at,
|
|
44
|
-
COALESCE((SELECT dr.folder FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
|
|
46
|
+
const ADMIN_DELIVERY = `'to' AS role,
|
|
47
|
+
(SELECT dr.read_at FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
|
|
48
|
+
ORDER BY dr.agent_id LIMIT 1) AS read_at,
|
|
49
|
+
COALESCE((SELECT dr.folder FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
|
|
45
50
|
ORDER BY dr.agent_id LIMIT 1), 'inbox') AS folder`;
|
|
46
51
|
/**
|
|
47
52
|
* Boîte aux lettres persistée en SQLite.
|
|
@@ -64,84 +69,108 @@ export class MessageStore extends EventEmitter {
|
|
|
64
69
|
this.migrate();
|
|
65
70
|
}
|
|
66
71
|
migrate() {
|
|
67
|
-
this.db.exec(`
|
|
68
|
-
CREATE TABLE IF NOT EXISTS agents (
|
|
69
|
-
id TEXT PRIMARY KEY,
|
|
70
|
-
agent_name TEXT,
|
|
71
|
-
agent_description TEXT,
|
|
72
|
-
display_name TEXT,
|
|
73
|
-
description TEXT,
|
|
74
|
-
created_at TEXT NOT NULL,
|
|
75
|
-
last_seen_at TEXT,
|
|
76
|
-
registered INTEGER NOT NULL DEFAULT 0
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
80
|
-
id TEXT PRIMARY KEY,
|
|
81
|
-
thread_id TEXT NOT NULL,
|
|
82
|
-
sender_id TEXT NOT NULL,
|
|
83
|
-
subject TEXT NOT NULL,
|
|
84
|
-
body TEXT NOT NULL,
|
|
85
|
-
priority TEXT NOT NULL DEFAULT 'normal',
|
|
86
|
-
in_reply_to TEXT,
|
|
87
|
-
created_at TEXT NOT NULL
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
CREATE TABLE IF NOT EXISTS deliveries (
|
|
91
|
-
id TEXT PRIMARY KEY,
|
|
92
|
-
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
93
|
-
agent_id TEXT NOT NULL,
|
|
94
|
-
role TEXT NOT NULL,
|
|
95
|
-
folder TEXT NOT NULL,
|
|
96
|
-
read_at TEXT,
|
|
97
|
-
starred INTEGER NOT NULL DEFAULT 0,
|
|
98
|
-
created_at TEXT NOT NULL,
|
|
99
|
-
UNIQUE (message_id, agent_id, role)
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
CREATE TABLE IF NOT EXISTS threads (
|
|
103
|
-
id TEXT PRIMARY KEY,
|
|
104
|
-
title TEXT,
|
|
105
|
-
description TEXT,
|
|
106
|
-
named_by TEXT,
|
|
107
|
-
created_at TEXT NOT NULL,
|
|
108
|
-
updated_at TEXT
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
CREATE TABLE IF NOT EXISTS folders (
|
|
112
|
-
agent_id TEXT NOT NULL,
|
|
113
|
-
name TEXT NOT NULL,
|
|
114
|
-
created_at TEXT NOT NULL,
|
|
115
|
-
PRIMARY KEY (agent_id, name)
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
CREATE
|
|
119
|
-
|
|
120
|
-
|
|
72
|
+
this.db.exec(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
agent_name TEXT,
|
|
76
|
+
agent_description TEXT,
|
|
77
|
+
display_name TEXT,
|
|
78
|
+
description TEXT,
|
|
79
|
+
created_at TEXT NOT NULL,
|
|
80
|
+
last_seen_at TEXT,
|
|
81
|
+
registered INTEGER NOT NULL DEFAULT 0
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
thread_id TEXT NOT NULL,
|
|
87
|
+
sender_id TEXT NOT NULL,
|
|
88
|
+
subject TEXT NOT NULL,
|
|
89
|
+
body TEXT NOT NULL,
|
|
90
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
91
|
+
in_reply_to TEXT,
|
|
92
|
+
created_at TEXT NOT NULL
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
96
|
+
id TEXT PRIMARY KEY,
|
|
97
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
98
|
+
agent_id TEXT NOT NULL,
|
|
99
|
+
role TEXT NOT NULL,
|
|
100
|
+
folder TEXT NOT NULL,
|
|
101
|
+
read_at TEXT,
|
|
102
|
+
starred INTEGER NOT NULL DEFAULT 0,
|
|
103
|
+
created_at TEXT NOT NULL,
|
|
104
|
+
UNIQUE (message_id, agent_id, role)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
108
|
+
id TEXT PRIMARY KEY,
|
|
109
|
+
title TEXT,
|
|
110
|
+
description TEXT,
|
|
111
|
+
named_by TEXT,
|
|
112
|
+
created_at TEXT NOT NULL,
|
|
113
|
+
updated_at TEXT
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
117
|
+
agent_id TEXT NOT NULL,
|
|
118
|
+
name TEXT NOT NULL,
|
|
119
|
+
created_at TEXT NOT NULL,
|
|
120
|
+
PRIMARY KEY (agent_id, name)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
124
|
+
id TEXT PRIMARY KEY,
|
|
125
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
126
|
+
position INTEGER NOT NULL,
|
|
127
|
+
filename TEXT NOT NULL,
|
|
128
|
+
mime_type TEXT NOT NULL,
|
|
129
|
+
size INTEGER NOT NULL,
|
|
130
|
+
data BLOB NOT NULL,
|
|
131
|
+
created_at TEXT NOT NULL
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS links (
|
|
135
|
+
id TEXT PRIMARY KEY,
|
|
136
|
+
mailbox_a TEXT NOT NULL,
|
|
137
|
+
mailbox_b TEXT NOT NULL,
|
|
138
|
+
note TEXT,
|
|
139
|
+
created_at TEXT NOT NULL,
|
|
140
|
+
seen_by_a_at TEXT,
|
|
141
|
+
seen_by_b_at TEXT,
|
|
142
|
+
UNIQUE (mailbox_a, mailbox_b)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
CREATE INDEX IF NOT EXISTS idx_links_b ON links (mailbox_b);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments (message_id, position);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
|
|
149
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
|
|
121
150
|
`);
|
|
122
151
|
// Bases antérieures à la séparation agent / boîte : `display_name` y tenait les deux
|
|
123
152
|
// rôles, et venait presque toujours de la configuration (le nom du projet). On le
|
|
124
153
|
// déplace vers l'identité de l'agent ; la boîte repart sans nom, à poser par l'agent.
|
|
125
154
|
const agentColumns = this.db.prepare("PRAGMA table_info(agents)").all().map((c) => c.name);
|
|
126
155
|
if (!agentColumns.includes("agent_name")) {
|
|
127
|
-
this.db.exec(`
|
|
128
|
-
ALTER TABLE agents ADD COLUMN agent_name TEXT;
|
|
129
|
-
ALTER TABLE agents ADD COLUMN agent_description TEXT;
|
|
130
|
-
UPDATE agents
|
|
131
|
-
SET agent_name = display_name,
|
|
132
|
-
agent_description = description,
|
|
133
|
-
display_name = NULL,
|
|
134
|
-
description = NULL;
|
|
156
|
+
this.db.exec(`
|
|
157
|
+
ALTER TABLE agents ADD COLUMN agent_name TEXT;
|
|
158
|
+
ALTER TABLE agents ADD COLUMN agent_description TEXT;
|
|
159
|
+
UPDATE agents
|
|
160
|
+
SET agent_name = display_name,
|
|
161
|
+
agent_description = description,
|
|
162
|
+
display_name = NULL,
|
|
163
|
+
description = NULL;
|
|
135
164
|
`);
|
|
136
165
|
}
|
|
137
166
|
// Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
|
|
138
167
|
// titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
|
|
139
|
-
this.db.exec(`
|
|
140
|
-
INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
141
|
-
SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
|
|
142
|
-
FROM messages m
|
|
143
|
-
WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
|
|
144
|
-
GROUP BY m.thread_id;
|
|
168
|
+
this.db.exec(`
|
|
169
|
+
INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
170
|
+
SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
|
|
171
|
+
FROM messages m
|
|
172
|
+
WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
|
|
173
|
+
GROUP BY m.thread_id;
|
|
145
174
|
`);
|
|
146
175
|
}
|
|
147
176
|
close() {
|
|
@@ -158,19 +187,19 @@ export class MessageStore extends EventEmitter {
|
|
|
158
187
|
const existing = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(id);
|
|
159
188
|
if (!existing) {
|
|
160
189
|
this.db
|
|
161
|
-
.prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
|
|
190
|
+
.prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
|
|
162
191
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
163
192
|
.run(id, opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
|
|
164
193
|
}
|
|
165
194
|
else {
|
|
166
195
|
this.db
|
|
167
|
-
.prepare(`UPDATE agents
|
|
168
|
-
SET agent_name = COALESCE(?, agent_name),
|
|
169
|
-
agent_description = COALESCE(?, agent_description),
|
|
170
|
-
display_name = COALESCE(?, display_name),
|
|
171
|
-
description = COALESCE(?, description),
|
|
172
|
-
registered = MAX(registered, ?),
|
|
173
|
-
last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
|
|
196
|
+
.prepare(`UPDATE agents
|
|
197
|
+
SET agent_name = COALESCE(?, agent_name),
|
|
198
|
+
agent_description = COALESCE(?, agent_description),
|
|
199
|
+
display_name = COALESCE(?, display_name),
|
|
200
|
+
description = COALESCE(?, description),
|
|
201
|
+
registered = MAX(registered, ?),
|
|
202
|
+
last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
|
|
174
203
|
WHERE id = ?`)
|
|
175
204
|
.run(opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
|
|
176
205
|
}
|
|
@@ -192,9 +221,9 @@ export class MessageStore extends EventEmitter {
|
|
|
192
221
|
listAgents(exclude) {
|
|
193
222
|
const excluded = exclude ? normalizeAgentId(exclude) : "";
|
|
194
223
|
const rows = this.db
|
|
195
|
-
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
|
|
196
|
-
FROM agents a
|
|
197
|
-
WHERE a.id != ?
|
|
224
|
+
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
|
|
225
|
+
FROM agents a
|
|
226
|
+
WHERE a.id != ?
|
|
198
227
|
ORDER BY last_activity_at DESC, a.id`)
|
|
199
228
|
.all(excluded);
|
|
200
229
|
return rows.map((r) => ({
|
|
@@ -223,6 +252,7 @@ export class MessageStore extends EventEmitter {
|
|
|
223
252
|
throw new MessengerError("an agent cannot send a message to itself");
|
|
224
253
|
const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
|
|
225
254
|
const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
|
|
255
|
+
const attachments = normalizeAttachments(input.attachments ?? []);
|
|
226
256
|
let threadId;
|
|
227
257
|
let inReplyTo = null;
|
|
228
258
|
if (input.reply_to) {
|
|
@@ -251,18 +281,23 @@ export class MessageStore extends EventEmitter {
|
|
|
251
281
|
// Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
|
|
252
282
|
// n'est touchée que si l'expéditeur fournit un nouveau titre.
|
|
253
283
|
this.db
|
|
254
|
-
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
255
|
-
VALUES (?, NULL, NULL, NULL, ?, NULL)
|
|
284
|
+
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
285
|
+
VALUES (?, NULL, NULL, NULL, ?, NULL)
|
|
256
286
|
ON CONFLICT (id) DO NOTHING`)
|
|
257
287
|
.run(threadId, now);
|
|
258
288
|
if (title !== null || threadDescription !== null) {
|
|
259
289
|
this.renameThreadRow(threadId, from, title, threadDescription, now);
|
|
260
290
|
}
|
|
261
291
|
this.db
|
|
262
|
-
.prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
|
|
292
|
+
.prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
|
|
263
293
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
264
294
|
.run(messageId, threadId, from, subject, input.body, priority, inReplyTo, now);
|
|
265
|
-
const
|
|
295
|
+
const insertAttachment = this.db.prepare(`INSERT INTO attachments (id, message_id, position, filename, mime_type, size, data, created_at)
|
|
296
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
297
|
+
attachments.forEach((a, position) => {
|
|
298
|
+
insertAttachment.run(newId("att"), messageId, position, a.filename, a.mime_type, a.data.length, a.data, now);
|
|
299
|
+
});
|
|
300
|
+
const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
|
|
266
301
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
|
|
267
302
|
for (const d of deliveries) {
|
|
268
303
|
insert.run(newId("dlv"), messageId, d.agent_id, d.role, d.folder, d.read_at, now);
|
|
@@ -307,27 +342,27 @@ export class MessageStore extends EventEmitter {
|
|
|
307
342
|
params.push(like, like);
|
|
308
343
|
}
|
|
309
344
|
const rows = this.db
|
|
310
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
311
|
-
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
312
|
-
FROM deliveries d
|
|
313
|
-
JOIN messages m ON m.id = d.message_id
|
|
314
|
-
${THREAD_JOIN}
|
|
315
|
-
WHERE ${where.join(" AND ")}
|
|
316
|
-
ORDER BY m.created_at DESC, m.id DESC
|
|
345
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
346
|
+
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
347
|
+
FROM deliveries d
|
|
348
|
+
JOIN messages m ON m.id = d.message_id
|
|
349
|
+
${THREAD_JOIN}
|
|
350
|
+
WHERE ${where.join(" AND ")}
|
|
351
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
317
352
|
LIMIT ? OFFSET ?`)
|
|
318
353
|
.all(...params, limit, offset);
|
|
319
|
-
const
|
|
320
|
-
return rows.map((row) => this.toSummary(row,
|
|
354
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
355
|
+
return rows.map((row) => this.toSummary(row, related));
|
|
321
356
|
}
|
|
322
357
|
getMessage(agentId, messageId, markRead = false) {
|
|
323
358
|
const row = this.db
|
|
324
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
325
|
-
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
326
|
-
FROM deliveries d
|
|
327
|
-
JOIN messages m ON m.id = d.message_id
|
|
328
|
-
${THREAD_JOIN}
|
|
329
|
-
WHERE d.agent_id = ? AND d.message_id = ?
|
|
330
|
-
ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
|
|
359
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
360
|
+
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
361
|
+
FROM deliveries d
|
|
362
|
+
JOIN messages m ON m.id = d.message_id
|
|
363
|
+
${THREAD_JOIN}
|
|
364
|
+
WHERE d.agent_id = ? AND d.message_id = ?
|
|
365
|
+
ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
|
|
331
366
|
LIMIT 1`)
|
|
332
367
|
.get(agentId, messageId);
|
|
333
368
|
if (!row)
|
|
@@ -339,26 +374,38 @@ export class MessageStore extends EventEmitter {
|
|
|
339
374
|
.run(now, agentId, messageId);
|
|
340
375
|
row.read_at = now;
|
|
341
376
|
}
|
|
342
|
-
const
|
|
343
|
-
return this.toMessage(row,
|
|
377
|
+
const related = this.relatedFor([messageId]);
|
|
378
|
+
return this.toMessage(row, related);
|
|
344
379
|
}
|
|
345
380
|
/** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
|
|
346
381
|
getThread(agentId, threadId) {
|
|
347
382
|
const rows = this.db
|
|
348
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
349
|
-
m.priority, m.in_reply_to, m.created_at,
|
|
350
|
-
MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
|
|
351
|
-
FROM deliveries d
|
|
352
|
-
JOIN messages m ON m.id = d.message_id
|
|
353
|
-
${THREAD_JOIN}
|
|
354
|
-
WHERE d.agent_id = ? AND m.thread_id = ?
|
|
355
|
-
GROUP BY m.id
|
|
383
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
384
|
+
m.priority, m.in_reply_to, m.created_at,
|
|
385
|
+
MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
|
|
386
|
+
FROM deliveries d
|
|
387
|
+
JOIN messages m ON m.id = d.message_id
|
|
388
|
+
${THREAD_JOIN}
|
|
389
|
+
WHERE d.agent_id = ? AND m.thread_id = ?
|
|
390
|
+
GROUP BY m.id
|
|
356
391
|
ORDER BY m.created_at ASC`)
|
|
357
392
|
.all(agentId, threadId);
|
|
358
393
|
if (rows.length === 0)
|
|
359
394
|
throw new MessengerError(`thread not found in this mailbox: ${threadId}`, 404);
|
|
360
|
-
const
|
|
361
|
-
return rows.map((row) => this.toMessage(row,
|
|
395
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
396
|
+
return rows.map((row) => this.toMessage(row, related));
|
|
397
|
+
}
|
|
398
|
+
/** Pièce jointe d'un message livré à cet agent, contenu compris. */
|
|
399
|
+
getAttachment(agentId, attachmentId) {
|
|
400
|
+
const row = this.db
|
|
401
|
+
.prepare(`SELECT a.id AS attachment_id, a.message_id, a.filename, a.mime_type, a.size, a.data
|
|
402
|
+
FROM attachments a
|
|
403
|
+
WHERE a.id = ?
|
|
404
|
+
AND EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = a.message_id AND d.agent_id = ?)`)
|
|
405
|
+
.get(attachmentId, agentId);
|
|
406
|
+
if (!row)
|
|
407
|
+
throw new MessengerError(`attachment not found in this mailbox: ${attachmentId}`, 404);
|
|
408
|
+
return row;
|
|
362
409
|
}
|
|
363
410
|
// --- Fils ---------------------------------------------------------------
|
|
364
411
|
getThreadInfo(threadId) {
|
|
@@ -398,7 +445,7 @@ export class MessageStore extends EventEmitter {
|
|
|
398
445
|
// qu'elle ait écrit ou seulement reçu. Les paramètres du WHERE se lient avant ceux du
|
|
399
446
|
// HAVING : cette condition doit donc être poussée avant celles de la recherche.
|
|
400
447
|
if (opts.with_agent) {
|
|
401
|
-
where.push(`EXISTS (SELECT 1 FROM deliveries d2 JOIN messages m2 ON m2.id = d2.message_id
|
|
448
|
+
where.push(`EXISTS (SELECT 1 FROM deliveries d2 JOIN messages m2 ON m2.id = d2.message_id
|
|
402
449
|
WHERE m2.thread_id = m.thread_id AND d2.agent_id = ?)`);
|
|
403
450
|
params.push(normalizeAgentId(opts.with_agent));
|
|
404
451
|
}
|
|
@@ -411,20 +458,20 @@ export class MessageStore extends EventEmitter {
|
|
|
411
458
|
params.push(like, like, like);
|
|
412
459
|
}
|
|
413
460
|
const rows = this.db
|
|
414
|
-
.prepare(`SELECT m.thread_id,
|
|
415
|
-
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
416
|
-
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
417
|
-
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
418
|
-
COUNT(DISTINCT m.id) AS messages,
|
|
419
|
-
COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
|
|
420
|
-
MAX(m.created_at) AS last_message_at
|
|
421
|
-
FROM deliveries d
|
|
422
|
-
JOIN messages m ON m.id = d.message_id
|
|
423
|
-
${THREAD_JOIN}
|
|
424
|
-
WHERE ${where.join(" AND ")}
|
|
425
|
-
GROUP BY m.thread_id
|
|
426
|
-
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
427
|
-
ORDER BY last_message_at DESC
|
|
461
|
+
.prepare(`SELECT m.thread_id,
|
|
462
|
+
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
463
|
+
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
464
|
+
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
465
|
+
COUNT(DISTINCT m.id) AS messages,
|
|
466
|
+
COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
|
|
467
|
+
MAX(m.created_at) AS last_message_at
|
|
468
|
+
FROM deliveries d
|
|
469
|
+
JOIN messages m ON m.id = d.message_id
|
|
470
|
+
${THREAD_JOIN}
|
|
471
|
+
WHERE ${where.join(" AND ")}
|
|
472
|
+
GROUP BY m.thread_id
|
|
473
|
+
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
474
|
+
ORDER BY last_message_at DESC
|
|
428
475
|
LIMIT ? OFFSET ?`)
|
|
429
476
|
.all(...params, limit, offset);
|
|
430
477
|
const participants = this.participantsFor(rows.map((r) => r.thread_id));
|
|
@@ -447,18 +494,18 @@ export class MessageStore extends EventEmitter {
|
|
|
447
494
|
/** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
|
|
448
495
|
renameThreadRow(threadId, agentId, title, description, now) {
|
|
449
496
|
this.db
|
|
450
|
-
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
451
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
452
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
453
|
-
title = COALESCE(excluded.title, threads.title),
|
|
454
|
-
description = COALESCE(excluded.description, threads.description),
|
|
455
|
-
named_by = excluded.named_by,
|
|
497
|
+
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
498
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
499
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
500
|
+
title = COALESCE(excluded.title, threads.title),
|
|
501
|
+
description = COALESCE(excluded.description, threads.description),
|
|
502
|
+
named_by = excluded.named_by,
|
|
456
503
|
updated_at = excluded.updated_at`)
|
|
457
504
|
.run(threadId, title, description, agentId, now, now);
|
|
458
505
|
}
|
|
459
506
|
assertThreadVisible(agentId, threadId) {
|
|
460
507
|
const row = this.db
|
|
461
|
-
.prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
508
|
+
.prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
462
509
|
WHERE d.agent_id = ? AND m.thread_id = ? LIMIT 1`)
|
|
463
510
|
.get(agentId, threadId);
|
|
464
511
|
if (!row)
|
|
@@ -471,9 +518,9 @@ export class MessageStore extends EventEmitter {
|
|
|
471
518
|
return map;
|
|
472
519
|
const placeholders = ids.map(() => "?").join(",");
|
|
473
520
|
const rows = this.db
|
|
474
|
-
.prepare(`SELECT DISTINCT m.thread_id, d.agent_id
|
|
475
|
-
FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
476
|
-
WHERE m.thread_id IN (${placeholders})
|
|
521
|
+
.prepare(`SELECT DISTINCT m.thread_id, d.agent_id
|
|
522
|
+
FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
523
|
+
WHERE m.thread_id IN (${placeholders})
|
|
477
524
|
ORDER BY d.agent_id`)
|
|
478
525
|
.all(...ids);
|
|
479
526
|
for (const row of rows) {
|
|
@@ -519,7 +566,7 @@ export class MessageStore extends EventEmitter {
|
|
|
519
566
|
listFolders(agentId) {
|
|
520
567
|
const custom = this.db.prepare("SELECT name FROM folders WHERE agent_id = ? ORDER BY name").all(agentId);
|
|
521
568
|
const counts = this.db
|
|
522
|
-
.prepare(`SELECT folder, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread
|
|
569
|
+
.prepare(`SELECT folder, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread
|
|
523
570
|
FROM deliveries WHERE agent_id = ? GROUP BY folder`)
|
|
524
571
|
.all(agentId);
|
|
525
572
|
const byName = new Map(counts.map((c) => [c.folder, c]));
|
|
@@ -558,7 +605,7 @@ export class MessageStore extends EventEmitter {
|
|
|
558
605
|
if (!exists)
|
|
559
606
|
throw new MessengerError(`folder not found: ${folder}`, 404);
|
|
560
607
|
const moved = this.db
|
|
561
|
-
.prepare(`UPDATE deliveries SET folder = CASE role WHEN 'sender' THEN 'sent' ELSE 'inbox' END
|
|
608
|
+
.prepare(`UPDATE deliveries SET folder = CASE role WHEN 'sender' THEN 'sent' ELSE 'inbox' END
|
|
562
609
|
WHERE agent_id = ? AND folder = ?`)
|
|
563
610
|
.run(agentId, folder).changes;
|
|
564
611
|
this.db.prepare("DELETE FROM folders WHERE agent_id = ? AND name = ?").run(agentId, folder);
|
|
@@ -591,10 +638,10 @@ export class MessageStore extends EventEmitter {
|
|
|
591
638
|
*/
|
|
592
639
|
staleMailboxes(before) {
|
|
593
640
|
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) < ?
|
|
641
|
+
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
|
|
642
|
+
(SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
|
|
643
|
+
FROM agents a
|
|
644
|
+
WHERE MAX(${LAST_ACTIVITY}, a.created_at) < ?
|
|
598
645
|
ORDER BY MAX(${LAST_ACTIVITY}, a.created_at) ASC`)
|
|
599
646
|
.all(before);
|
|
600
647
|
return rows.map((row) => ({
|
|
@@ -613,10 +660,10 @@ export class MessageStore extends EventEmitter {
|
|
|
613
660
|
*/
|
|
614
661
|
mailboxesCreatedBefore(before) {
|
|
615
662
|
const rows = this.db
|
|
616
|
-
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
|
|
617
|
-
(SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
|
|
618
|
-
FROM agents a
|
|
619
|
-
WHERE a.created_at < ?
|
|
663
|
+
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
|
|
664
|
+
(SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
|
|
665
|
+
FROM agents a
|
|
666
|
+
WHERE a.created_at < ?
|
|
620
667
|
ORDER BY a.created_at ASC`)
|
|
621
668
|
.all(before);
|
|
622
669
|
return rows.map((row) => ({
|
|
@@ -635,9 +682,9 @@ export class MessageStore extends EventEmitter {
|
|
|
635
682
|
*/
|
|
636
683
|
emptyMailboxes() {
|
|
637
684
|
const rows = this.db
|
|
638
|
-
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at, 0 AS messages
|
|
639
|
-
FROM agents a
|
|
640
|
-
WHERE NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.agent_id = a.id)
|
|
685
|
+
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at, 0 AS messages
|
|
686
|
+
FROM agents a
|
|
687
|
+
WHERE NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.agent_id = a.id)
|
|
641
688
|
ORDER BY a.created_at ASC`)
|
|
642
689
|
.all();
|
|
643
690
|
return rows.map((row) => ({
|
|
@@ -665,9 +712,11 @@ export class MessageStore extends EventEmitter {
|
|
|
665
712
|
const dropDeliveries = this.db.prepare("DELETE FROM deliveries WHERE agent_id = ?");
|
|
666
713
|
const dropFolders = this.db.prepare("DELETE FROM folders WHERE agent_id = ?");
|
|
667
714
|
const dropAgent = this.db.prepare("DELETE FROM agents WHERE id = ?");
|
|
715
|
+
const dropLinks = this.db.prepare("DELETE FROM links WHERE mailbox_a = ? OR mailbox_b = ?");
|
|
668
716
|
for (const id of ids) {
|
|
669
717
|
deliveries += dropDeliveries.run(id).changes;
|
|
670
718
|
dropFolders.run(id);
|
|
719
|
+
dropLinks.run(id, id);
|
|
671
720
|
mailboxes += dropAgent.run(id).changes;
|
|
672
721
|
}
|
|
673
722
|
this.dropOrphanMessages();
|
|
@@ -681,6 +730,74 @@ export class MessageStore extends EventEmitter {
|
|
|
681
730
|
};
|
|
682
731
|
})();
|
|
683
732
|
}
|
|
733
|
+
// --- Liaisons -----------------------------------------------------------
|
|
734
|
+
/**
|
|
735
|
+
* Lie deux boîtes existantes. La paire est stockée dans l'ordre des adresses : une
|
|
736
|
+
* liaison n'a pas de direction, et la même paire ne peut être liée deux fois.
|
|
737
|
+
*/
|
|
738
|
+
createLink(first, second, note) {
|
|
739
|
+
const ids = [normalizeAgentId(first), normalizeAgentId(second)];
|
|
740
|
+
if (!ids[0] || !ids[1])
|
|
741
|
+
throw new MessengerError("two mailbox addresses are required");
|
|
742
|
+
if (ids[0] === ids[1])
|
|
743
|
+
throw new MessengerError("a mailbox cannot be linked to itself");
|
|
744
|
+
for (const id of ids) {
|
|
745
|
+
if (!this.getAgent(id))
|
|
746
|
+
throw new MessengerError(`mailbox not found: ${id}`, 404);
|
|
747
|
+
}
|
|
748
|
+
const [a, b] = ids.sort();
|
|
749
|
+
if (this.db.prepare("SELECT 1 FROM links WHERE mailbox_a = ? AND mailbox_b = ?").get(a, b)) {
|
|
750
|
+
throw new MessengerError(`these mailboxes are already linked: ${a}, ${b}`, 409);
|
|
751
|
+
}
|
|
752
|
+
const id = newId("lnk");
|
|
753
|
+
this.db
|
|
754
|
+
.prepare("INSERT INTO links (id, mailbox_a, mailbox_b, note, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
755
|
+
.run(id, a, b, normalizeLabel(note, LINK_NOTE_MAX, "note"), new Date().toISOString());
|
|
756
|
+
for (const agentId of [a, b])
|
|
757
|
+
this.emit("link", { agent_id: agentId, link_id: id });
|
|
758
|
+
return this.getLink(id);
|
|
759
|
+
}
|
|
760
|
+
getLink(linkId) {
|
|
761
|
+
const row = this.db.prepare("SELECT * FROM links WHERE id = ?").get(linkId);
|
|
762
|
+
return row ? toLink(row) : null;
|
|
763
|
+
}
|
|
764
|
+
/** Toutes les liaisons du serveur, la plus récente en tête. */
|
|
765
|
+
listLinks() {
|
|
766
|
+
const rows = this.db.prepare("SELECT * FROM links ORDER BY created_at DESC, rowid DESC").all();
|
|
767
|
+
return rows.map(toLink);
|
|
768
|
+
}
|
|
769
|
+
deleteLink(linkId) {
|
|
770
|
+
const removed = this.db.prepare("DELETE FROM links WHERE id = ?").run(linkId).changes;
|
|
771
|
+
if (removed === 0)
|
|
772
|
+
throw new MessengerError(`link not found: ${linkId}`, 404);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Liaisons d'une boîte, la plus récente en tête, chacune avec la fiche de la boîte d'en
|
|
776
|
+
* face. `markSeen` note que l'agent de cette boîte en a pris connaissance.
|
|
777
|
+
*/
|
|
778
|
+
linksFor(agentId, opts = {}) {
|
|
779
|
+
const id = normalizeAgentId(agentId);
|
|
780
|
+
const where = ["(mailbox_a = ? OR mailbox_b = ?)"];
|
|
781
|
+
const params = [id, id];
|
|
782
|
+
if (opts.linkId) {
|
|
783
|
+
where.push("id = ?");
|
|
784
|
+
params.push(opts.linkId);
|
|
785
|
+
}
|
|
786
|
+
const rows = this.db
|
|
787
|
+
.prepare(`SELECT * FROM links WHERE ${where.join(" AND ")} ORDER BY created_at DESC, rowid DESC`)
|
|
788
|
+
.all(...params);
|
|
789
|
+
if (opts.markSeen && rows.length > 0) {
|
|
790
|
+
const now = new Date().toISOString();
|
|
791
|
+
this.db.prepare("UPDATE links SET seen_by_a_at = ? WHERE mailbox_a = ? AND seen_by_a_at IS NULL").run(now, id);
|
|
792
|
+
this.db.prepare("UPDATE links SET seen_by_b_at = ? WHERE mailbox_b = ? AND seen_by_b_at IS NULL").run(now, id);
|
|
793
|
+
}
|
|
794
|
+
return rows.map((row) => ({
|
|
795
|
+
link_id: row.id,
|
|
796
|
+
note: row.note,
|
|
797
|
+
created_at: row.created_at,
|
|
798
|
+
correspondent: this.getAgent(row.mailbox_a === id ? row.mailbox_b : row.mailbox_a),
|
|
799
|
+
}));
|
|
800
|
+
}
|
|
684
801
|
// --- Supervision (interface web, lecture seule sauf purge) ---------------
|
|
685
802
|
/** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
|
|
686
803
|
listAllMessages(opts = {}) {
|
|
@@ -702,16 +819,16 @@ export class MessageStore extends EventEmitter {
|
|
|
702
819
|
params.push(opts.thread_id);
|
|
703
820
|
}
|
|
704
821
|
const rows = this.db
|
|
705
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
706
|
-
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
707
|
-
FROM messages m
|
|
708
|
-
${THREAD_JOIN}
|
|
709
|
-
WHERE ${where.join(" AND ")}
|
|
710
|
-
ORDER BY m.created_at DESC, m.id DESC
|
|
822
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
823
|
+
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
824
|
+
FROM messages m
|
|
825
|
+
${THREAD_JOIN}
|
|
826
|
+
WHERE ${where.join(" AND ")}
|
|
827
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
711
828
|
LIMIT ? OFFSET ?`)
|
|
712
829
|
.all(...params, limit, offset);
|
|
713
|
-
const
|
|
714
|
-
return rows.map((row) => this.toSummary(row,
|
|
830
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
831
|
+
return rows.map((row) => this.toSummary(row, related));
|
|
715
832
|
}
|
|
716
833
|
/**
|
|
717
834
|
* Fils du serveur entier, le plus récemment actif en tête.
|
|
@@ -730,7 +847,7 @@ export class MessageStore extends EventEmitter {
|
|
|
730
847
|
// reçu : c'est la correspondance qui est filtrée, pas les messages qu'elle a signés.
|
|
731
848
|
const agents = dedupe((opts.agents ?? []).map((id) => normalizeAgentId(id)).filter(Boolean));
|
|
732
849
|
if (agents.length > 0) {
|
|
733
|
-
where.push(`EXISTS (SELECT 1 FROM deliveries d JOIN messages mp ON mp.id = d.message_id
|
|
850
|
+
where.push(`EXISTS (SELECT 1 FROM deliveries d JOIN messages mp ON mp.id = d.message_id
|
|
734
851
|
WHERE mp.thread_id = m.thread_id AND d.agent_id IN (${agents.map(() => "?").join(",")}))`);
|
|
735
852
|
params.push(...agents);
|
|
736
853
|
}
|
|
@@ -738,8 +855,8 @@ export class MessageStore extends EventEmitter {
|
|
|
738
855
|
// n'importe lequel de ses messages. Un fil non nommé reste ainsi trouvable.
|
|
739
856
|
if (opts.search) {
|
|
740
857
|
const like = `%${escapeLike(opts.search)}%`;
|
|
741
|
-
where.push(`(COALESCE(t.title, '') LIKE ? ESCAPE '\\' OR COALESCE(t.description, '') LIKE ? ESCAPE '\\'
|
|
742
|
-
OR EXISTS (SELECT 1 FROM messages ms WHERE ms.thread_id = m.thread_id
|
|
858
|
+
where.push(`(COALESCE(t.title, '') LIKE ? ESCAPE '\\' OR COALESCE(t.description, '') LIKE ? ESCAPE '\\'
|
|
859
|
+
OR EXISTS (SELECT 1 FROM messages ms WHERE ms.thread_id = m.thread_id
|
|
743
860
|
AND (ms.subject LIKE ? ESCAPE '\\' OR ms.body LIKE ? ESCAPE '\\')))`);
|
|
744
861
|
params.push(like, like, like, like);
|
|
745
862
|
}
|
|
@@ -748,28 +865,28 @@ export class MessageStore extends EventEmitter {
|
|
|
748
865
|
if (opts.priority)
|
|
749
866
|
having.push("top_priority = ?");
|
|
750
867
|
const rows = this.db
|
|
751
|
-
.prepare(`SELECT m.thread_id,
|
|
752
|
-
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
753
|
-
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
754
|
-
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
755
|
-
COUNT(*) AS messages,
|
|
756
|
-
MAX(m.created_at) AS last_message_at,
|
|
757
|
-
(SELECT r.sender_id FROM messages r WHERE r.thread_id = m.thread_id
|
|
758
|
-
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_message_from,
|
|
759
|
-
(SELECT r.body FROM messages r WHERE r.thread_id = m.thread_id
|
|
760
|
-
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_body,
|
|
761
|
-
(SELECT COUNT(*) FROM deliveries d JOIN messages mu ON mu.id = d.message_id
|
|
762
|
-
WHERE mu.thread_id = m.thread_id AND d.role != 'sender'
|
|
763
|
-
AND d.read_at IS NULL AND d.folder != 'trash') AS unread,
|
|
764
|
-
CASE WHEN SUM(m.priority = 'high') > 0 THEN 'high'
|
|
765
|
-
WHEN SUM(m.priority = 'normal') > 0 THEN 'normal'
|
|
766
|
-
ELSE 'low' END AS top_priority
|
|
767
|
-
FROM messages m
|
|
768
|
-
${THREAD_JOIN}
|
|
769
|
-
WHERE ${where.join(" AND ")}
|
|
770
|
-
GROUP BY m.thread_id
|
|
771
|
-
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
772
|
-
ORDER BY last_message_at DESC
|
|
868
|
+
.prepare(`SELECT m.thread_id,
|
|
869
|
+
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
870
|
+
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
871
|
+
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
872
|
+
COUNT(*) AS messages,
|
|
873
|
+
MAX(m.created_at) AS last_message_at,
|
|
874
|
+
(SELECT r.sender_id FROM messages r WHERE r.thread_id = m.thread_id
|
|
875
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_message_from,
|
|
876
|
+
(SELECT r.body FROM messages r WHERE r.thread_id = m.thread_id
|
|
877
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_body,
|
|
878
|
+
(SELECT COUNT(*) FROM deliveries d JOIN messages mu ON mu.id = d.message_id
|
|
879
|
+
WHERE mu.thread_id = m.thread_id AND d.role != 'sender'
|
|
880
|
+
AND d.read_at IS NULL AND d.folder != 'trash') AS unread,
|
|
881
|
+
CASE WHEN SUM(m.priority = 'high') > 0 THEN 'high'
|
|
882
|
+
WHEN SUM(m.priority = 'normal') > 0 THEN 'normal'
|
|
883
|
+
ELSE 'low' END AS top_priority
|
|
884
|
+
FROM messages m
|
|
885
|
+
${THREAD_JOIN}
|
|
886
|
+
WHERE ${where.join(" AND ")}
|
|
887
|
+
GROUP BY m.thread_id
|
|
888
|
+
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
889
|
+
ORDER BY last_message_at DESC
|
|
773
890
|
LIMIT ? OFFSET ?`)
|
|
774
891
|
.all(...params, ...(opts.priority ? [opts.priority] : []), limit, offset);
|
|
775
892
|
const participants = this.participantsFor(rows.map((r) => r.thread_id));
|
|
@@ -799,31 +916,31 @@ export class MessageStore extends EventEmitter {
|
|
|
799
916
|
*/
|
|
800
917
|
listThreadAdmin(threadId) {
|
|
801
918
|
const rows = this.db
|
|
802
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
803
|
-
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
804
|
-
FROM messages m
|
|
805
|
-
${THREAD_JOIN}
|
|
806
|
-
WHERE m.thread_id = ?
|
|
919
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
920
|
+
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
921
|
+
FROM messages m
|
|
922
|
+
${THREAD_JOIN}
|
|
923
|
+
WHERE m.thread_id = ?
|
|
807
924
|
ORDER BY m.created_at ASC, m.id ASC`)
|
|
808
925
|
.all(threadId);
|
|
809
|
-
const
|
|
810
|
-
return rows.map((row) => ({ ...this.toSummary(row,
|
|
926
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
927
|
+
return rows.map((row) => ({ ...this.toSummary(row, related), body: row.body }));
|
|
811
928
|
}
|
|
812
929
|
/** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
|
|
813
930
|
getMessageAdmin(messageId) {
|
|
814
931
|
const row = this.db
|
|
815
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
816
|
-
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
932
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
933
|
+
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
817
934
|
FROM messages m ${THREAD_JOIN} WHERE m.id = ?`)
|
|
818
935
|
.get(messageId);
|
|
819
936
|
if (!row)
|
|
820
937
|
throw new MessengerError(`message not found: ${messageId}`, 404);
|
|
821
|
-
const
|
|
938
|
+
const related = this.relatedFor([messageId]);
|
|
822
939
|
const deliveries = this.db
|
|
823
940
|
.prepare("SELECT agent_id, role, folder, read_at FROM deliveries WHERE message_id = ? ORDER BY role, agent_id")
|
|
824
941
|
.all(messageId);
|
|
825
942
|
return {
|
|
826
|
-
...this.toMessage(row,
|
|
943
|
+
...this.toMessage(row, related),
|
|
827
944
|
deliveries: deliveries.map((d) => ({
|
|
828
945
|
agent_id: d.agent_id,
|
|
829
946
|
role: d.role,
|
|
@@ -832,14 +949,24 @@ export class MessageStore extends EventEmitter {
|
|
|
832
949
|
})),
|
|
833
950
|
};
|
|
834
951
|
}
|
|
952
|
+
/** Pièce jointe vue du serveur, quelle que soit la boîte. */
|
|
953
|
+
getAttachmentAdmin(attachmentId) {
|
|
954
|
+
const row = this.db
|
|
955
|
+
.prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size, data
|
|
956
|
+
FROM attachments WHERE id = ?`)
|
|
957
|
+
.get(attachmentId);
|
|
958
|
+
if (!row)
|
|
959
|
+
throw new MessengerError(`attachment not found: ${attachmentId}`, 404);
|
|
960
|
+
return row;
|
|
961
|
+
}
|
|
835
962
|
overview() {
|
|
836
963
|
const messages = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
|
|
837
964
|
const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
|
|
838
965
|
const sentByAgent = new Map(this.db.prepare("SELECT sender_id, COUNT(*) AS n FROM messages GROUP BY sender_id").all().map((r) => [r.sender_id, r.n]));
|
|
839
966
|
const receivedByAgent = new Map(this.db
|
|
840
|
-
.prepare(`SELECT agent_id,
|
|
841
|
-
COUNT(*) AS received,
|
|
842
|
-
SUM(CASE WHEN read_at IS NULL AND folder != 'trash' THEN 1 ELSE 0 END) AS unread
|
|
967
|
+
.prepare(`SELECT agent_id,
|
|
968
|
+
COUNT(*) AS received,
|
|
969
|
+
SUM(CASE WHEN read_at IS NULL AND folder != 'trash' THEN 1 ELSE 0 END) AS unread
|
|
843
970
|
FROM deliveries WHERE role != 'sender' GROUP BY agent_id`)
|
|
844
971
|
.all().map((r) => [r.agent_id, r]));
|
|
845
972
|
const agents = this.listAgents().map((agent) => ({
|
|
@@ -878,28 +1005,36 @@ export class MessageStore extends EventEmitter {
|
|
|
878
1005
|
if (!row)
|
|
879
1006
|
throw new MessengerError(`unknown folder: ${folder}`, 404);
|
|
880
1007
|
}
|
|
881
|
-
/** Destinataire de chaque message demandé, par identifiant de message. */
|
|
882
|
-
|
|
883
|
-
const
|
|
1008
|
+
/** Destinataire et pièces jointes de chaque message demandé, par identifiant de message. */
|
|
1009
|
+
relatedFor(messageIds) {
|
|
1010
|
+
const related = { recipients: new Map(), attachments: new Map() };
|
|
884
1011
|
const ids = dedupe(messageIds);
|
|
885
1012
|
if (ids.length === 0)
|
|
886
|
-
return
|
|
1013
|
+
return related;
|
|
887
1014
|
const placeholders = ids.map(() => "?").join(",");
|
|
888
|
-
const
|
|
889
|
-
.prepare(`SELECT message_id, agent_id FROM deliveries
|
|
1015
|
+
const recipients = this.db
|
|
1016
|
+
.prepare(`SELECT message_id, agent_id FROM deliveries
|
|
890
1017
|
WHERE message_id IN (${placeholders}) AND role = 'to'`)
|
|
891
1018
|
.all(...ids);
|
|
892
|
-
for (const row of
|
|
893
|
-
|
|
894
|
-
|
|
1019
|
+
for (const row of recipients)
|
|
1020
|
+
related.recipients.set(row.message_id, row.agent_id);
|
|
1021
|
+
const attachments = this.db
|
|
1022
|
+
.prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size FROM attachments
|
|
1023
|
+
WHERE message_id IN (${placeholders})
|
|
1024
|
+
ORDER BY message_id, position`)
|
|
1025
|
+
.all(...ids);
|
|
1026
|
+
for (const { message_id, ...info } of attachments) {
|
|
1027
|
+
related.attachments.set(message_id, [...(related.attachments.get(message_id) ?? []), info]);
|
|
1028
|
+
}
|
|
1029
|
+
return related;
|
|
895
1030
|
}
|
|
896
|
-
toSummary(row,
|
|
1031
|
+
toSummary(row, related) {
|
|
897
1032
|
return {
|
|
898
1033
|
message_id: row.message_id,
|
|
899
1034
|
thread_id: row.thread_id,
|
|
900
1035
|
thread_title: row.thread_title,
|
|
901
1036
|
from: row.sender_id,
|
|
902
|
-
to: recipients.get(row.message_id) ?? "",
|
|
1037
|
+
to: related.recipients.get(row.message_id) ?? "",
|
|
903
1038
|
subject: row.subject,
|
|
904
1039
|
preview: preview(row.body),
|
|
905
1040
|
priority: row.priority,
|
|
@@ -909,10 +1044,11 @@ export class MessageStore extends EventEmitter {
|
|
|
909
1044
|
starred: Boolean(row.starred),
|
|
910
1045
|
created_at: row.created_at,
|
|
911
1046
|
in_reply_to: row.in_reply_to,
|
|
1047
|
+
attachments: related.attachments.get(row.message_id) ?? [],
|
|
912
1048
|
};
|
|
913
1049
|
}
|
|
914
|
-
toMessage(row,
|
|
915
|
-
const { preview: _preview, ...summary } = this.toSummary(row,
|
|
1050
|
+
toMessage(row, related) {
|
|
1051
|
+
const { preview: _preview, ...summary } = this.toSummary(row, related);
|
|
916
1052
|
return { ...summary, body: row.body };
|
|
917
1053
|
}
|
|
918
1054
|
/** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
|
|
@@ -922,6 +1058,17 @@ export class MessageStore extends EventEmitter {
|
|
|
922
1058
|
this.db.exec("DELETE FROM threads WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.thread_id = threads.id)");
|
|
923
1059
|
}
|
|
924
1060
|
}
|
|
1061
|
+
function toLink(row) {
|
|
1062
|
+
return {
|
|
1063
|
+
link_id: row.id,
|
|
1064
|
+
note: row.note,
|
|
1065
|
+
created_at: row.created_at,
|
|
1066
|
+
mailboxes: [
|
|
1067
|
+
{ mailbox: row.mailbox_a, seen_at: row.seen_by_a_at },
|
|
1068
|
+
{ mailbox: row.mailbox_b, seen_at: row.seen_by_b_at },
|
|
1069
|
+
],
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
925
1072
|
export function normalizeAgentId(id) {
|
|
926
1073
|
return String(id ?? "").trim();
|
|
927
1074
|
}
|
|
@@ -941,6 +1088,47 @@ function normalizeLabel(value, max, field) {
|
|
|
941
1088
|
throw new MessengerError(`${field} must be at most ${max} characters`);
|
|
942
1089
|
return trimmed;
|
|
943
1090
|
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Valide les pièces jointes d'un envoi : nombre, tailles, nom et type.
|
|
1093
|
+
*
|
|
1094
|
+
* Le nom ne garde que sa dernière composante : il sert à nommer un fichier chez le
|
|
1095
|
+
* destinataire, et ne doit jamais désigner un autre répertoire que celui qu'il choisit.
|
|
1096
|
+
*/
|
|
1097
|
+
function normalizeAttachments(attachments) {
|
|
1098
|
+
if (attachments.length > MAX_ATTACHMENTS) {
|
|
1099
|
+
throw new MessengerError(`at most ${MAX_ATTACHMENTS} attachments per message`);
|
|
1100
|
+
}
|
|
1101
|
+
let total = 0;
|
|
1102
|
+
const normalized = attachments.map((attachment) => {
|
|
1103
|
+
const filename = sanitizeFilename(attachment.filename);
|
|
1104
|
+
if (attachment.data.length === 0)
|
|
1105
|
+
throw new MessengerError(`attachment is empty: ${filename}`);
|
|
1106
|
+
if (attachment.data.length > MAX_ATTACHMENT_BYTES) {
|
|
1107
|
+
throw new MessengerError(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${filename}`, 413);
|
|
1108
|
+
}
|
|
1109
|
+
total += attachment.data.length;
|
|
1110
|
+
const mimeType = (attachment.mime_type ?? "").trim().toLowerCase() || DEFAULT_MIME_TYPE;
|
|
1111
|
+
if (!MIME_TYPE_RE.test(mimeType))
|
|
1112
|
+
throw new MessengerError(`invalid mime_type for ${filename}: ${mimeType}`);
|
|
1113
|
+
return { filename, mime_type: mimeType, data: attachment.data };
|
|
1114
|
+
});
|
|
1115
|
+
if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
|
|
1116
|
+
throw new MessengerError(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`, 413);
|
|
1117
|
+
}
|
|
1118
|
+
return normalized;
|
|
1119
|
+
}
|
|
1120
|
+
function sanitizeFilename(value) {
|
|
1121
|
+
const base = String(value ?? "")
|
|
1122
|
+
.split(/[\\/]/)
|
|
1123
|
+
.pop()
|
|
1124
|
+
.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, "_")
|
|
1125
|
+
.trim();
|
|
1126
|
+
if (!base || base === "." || base === "..")
|
|
1127
|
+
throw new MessengerError("attachment filename cannot be empty");
|
|
1128
|
+
if (base.length > FILENAME_MAX)
|
|
1129
|
+
throw new MessengerError(`attachment filename must be at most ${FILENAME_MAX} characters`);
|
|
1130
|
+
return base;
|
|
1131
|
+
}
|
|
944
1132
|
function dedupe(values) {
|
|
945
1133
|
return [...new Set(values)];
|
|
946
1134
|
}
|