@imenam/mcp-messenger 1.0.30 → 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.
@@ -22,14 +22,15 @@ const THREAD_DESCRIPTION_MAX = 2000;
22
22
  const FILENAME_MAX = 255;
23
23
  const MIME_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
24
24
  const DEFAULT_MIME_TYPE = "application/octet-stream";
25
+ const LINK_NOTE_MAX = 500;
25
26
  /**
26
27
  * Dernière activité d'une boîte : sa dernière connexion, ou le dernier message qui l'a
27
28
  * touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
28
29
  * seul la ferait paraître abandonnée.
29
30
  */
30
- const LAST_ACTIVITY = `MAX(
31
- COALESCE(a.last_seen_at, ''),
32
- 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), '')
33
34
  )`;
34
35
  /** Colonnes du fil jointes à tout message : le titre voyage avec chaque livraison. */
35
36
  const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
@@ -42,10 +43,10 @@ const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
42
43
  * pose. Un message n'ayant qu'une copie d'expéditeur (destinataire jamais enregistré) n'a
43
44
  * pas de ligne ici : `read_at` reste alors nul, donc non lu.
44
45
  */
45
- const ADMIN_DELIVERY = `'to' AS role,
46
- (SELECT dr.read_at FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
47
- ORDER BY dr.agent_id LIMIT 1) AS read_at,
48
- 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'
49
50
  ORDER BY dr.agent_id LIMIT 1), 'inbox') AS folder`;
50
51
  /**
51
52
  * Boîte aux lettres persistée en SQLite.
@@ -68,96 +69,108 @@ export class MessageStore extends EventEmitter {
68
69
  this.migrate();
69
70
  }
70
71
  migrate() {
71
- this.db.exec(`
72
- CREATE TABLE IF NOT EXISTS agents (
73
- id TEXT PRIMARY KEY,
74
- agent_name TEXT,
75
- agent_description TEXT,
76
- display_name TEXT,
77
- description TEXT,
78
- created_at TEXT NOT NULL,
79
- last_seen_at TEXT,
80
- registered INTEGER NOT NULL DEFAULT 0
81
- );
82
-
83
- CREATE TABLE IF NOT EXISTS messages (
84
- id TEXT PRIMARY KEY,
85
- thread_id TEXT NOT NULL,
86
- sender_id TEXT NOT NULL,
87
- subject TEXT NOT NULL,
88
- body TEXT NOT NULL,
89
- priority TEXT NOT NULL DEFAULT 'normal',
90
- in_reply_to TEXT,
91
- created_at TEXT NOT NULL
92
- );
93
-
94
- CREATE TABLE IF NOT EXISTS deliveries (
95
- id TEXT PRIMARY KEY,
96
- message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
97
- agent_id TEXT NOT NULL,
98
- role TEXT NOT NULL,
99
- folder TEXT NOT NULL,
100
- read_at TEXT,
101
- starred INTEGER NOT NULL DEFAULT 0,
102
- created_at TEXT NOT NULL,
103
- UNIQUE (message_id, agent_id, role)
104
- );
105
-
106
- CREATE TABLE IF NOT EXISTS threads (
107
- id TEXT PRIMARY KEY,
108
- title TEXT,
109
- description TEXT,
110
- named_by TEXT,
111
- created_at TEXT NOT NULL,
112
- updated_at TEXT
113
- );
114
-
115
- CREATE TABLE IF NOT EXISTS folders (
116
- agent_id TEXT NOT NULL,
117
- name TEXT NOT NULL,
118
- created_at TEXT NOT NULL,
119
- PRIMARY KEY (agent_id, name)
120
- );
121
-
122
- CREATE TABLE IF NOT EXISTS attachments (
123
- id TEXT PRIMARY KEY,
124
- message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
125
- position INTEGER NOT NULL,
126
- filename TEXT NOT NULL,
127
- mime_type TEXT NOT NULL,
128
- size INTEGER NOT NULL,
129
- data BLOB NOT NULL,
130
- created_at TEXT NOT NULL
131
- );
132
-
133
- CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments (message_id, position);
134
- CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
135
- CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
136
- CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
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);
137
150
  `);
138
151
  // Bases antérieures à la séparation agent / boîte : `display_name` y tenait les deux
139
152
  // rôles, et venait presque toujours de la configuration (le nom du projet). On le
140
153
  // déplace vers l'identité de l'agent ; la boîte repart sans nom, à poser par l'agent.
141
154
  const agentColumns = this.db.prepare("PRAGMA table_info(agents)").all().map((c) => c.name);
142
155
  if (!agentColumns.includes("agent_name")) {
143
- this.db.exec(`
144
- ALTER TABLE agents ADD COLUMN agent_name TEXT;
145
- ALTER TABLE agents ADD COLUMN agent_description TEXT;
146
- UPDATE agents
147
- SET agent_name = display_name,
148
- agent_description = description,
149
- display_name = NULL,
150
- 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;
151
164
  `);
152
165
  }
153
166
  // Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
154
167
  // titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
155
- this.db.exec(`
156
- INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
157
- SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
158
- FROM messages m
159
- WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
160
- 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;
161
174
  `);
162
175
  }
163
176
  close() {
@@ -174,19 +187,19 @@ export class MessageStore extends EventEmitter {
174
187
  const existing = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(id);
175
188
  if (!existing) {
176
189
  this.db
177
- .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)
178
191
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
179
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);
180
193
  }
181
194
  else {
182
195
  this.db
183
- .prepare(`UPDATE agents
184
- SET agent_name = COALESCE(?, agent_name),
185
- agent_description = COALESCE(?, agent_description),
186
- display_name = COALESCE(?, display_name),
187
- description = COALESCE(?, description),
188
- registered = MAX(registered, ?),
189
- 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
190
203
  WHERE id = ?`)
191
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);
192
205
  }
@@ -208,9 +221,9 @@ export class MessageStore extends EventEmitter {
208
221
  listAgents(exclude) {
209
222
  const excluded = exclude ? normalizeAgentId(exclude) : "";
210
223
  const rows = this.db
211
- .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
212
- FROM agents a
213
- WHERE a.id != ?
224
+ .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
225
+ FROM agents a
226
+ WHERE a.id != ?
214
227
  ORDER BY last_activity_at DESC, a.id`)
215
228
  .all(excluded);
216
229
  return rows.map((r) => ({
@@ -268,23 +281,23 @@ export class MessageStore extends EventEmitter {
268
281
  // Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
269
282
  // n'est touchée que si l'expéditeur fournit un nouveau titre.
270
283
  this.db
271
- .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
272
- 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)
273
286
  ON CONFLICT (id) DO NOTHING`)
274
287
  .run(threadId, now);
275
288
  if (title !== null || threadDescription !== null) {
276
289
  this.renameThreadRow(threadId, from, title, threadDescription, now);
277
290
  }
278
291
  this.db
279
- .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)
280
293
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
281
294
  .run(messageId, threadId, from, subject, input.body, priority, inReplyTo, now);
282
- const insertAttachment = this.db.prepare(`INSERT INTO attachments (id, message_id, position, filename, mime_type, size, data, created_at)
295
+ const insertAttachment = this.db.prepare(`INSERT INTO attachments (id, message_id, position, filename, mime_type, size, data, created_at)
283
296
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
284
297
  attachments.forEach((a, position) => {
285
298
  insertAttachment.run(newId("att"), messageId, position, a.filename, a.mime_type, a.data.length, a.data, now);
286
299
  });
287
- const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
300
+ const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
288
301
  VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
289
302
  for (const d of deliveries) {
290
303
  insert.run(newId("dlv"), messageId, d.agent_id, d.role, d.folder, d.read_at, now);
@@ -329,13 +342,13 @@ export class MessageStore extends EventEmitter {
329
342
  params.push(like, like);
330
343
  }
331
344
  const rows = this.db
332
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
333
- m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
334
- FROM deliveries d
335
- JOIN messages m ON m.id = d.message_id
336
- ${THREAD_JOIN}
337
- WHERE ${where.join(" AND ")}
338
- 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
339
352
  LIMIT ? OFFSET ?`)
340
353
  .all(...params, limit, offset);
341
354
  const related = this.relatedFor(rows.map((r) => r.message_id));
@@ -343,13 +356,13 @@ export class MessageStore extends EventEmitter {
343
356
  }
344
357
  getMessage(agentId, messageId, markRead = false) {
345
358
  const row = this.db
346
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
347
- m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
348
- FROM deliveries d
349
- JOIN messages m ON m.id = d.message_id
350
- ${THREAD_JOIN}
351
- WHERE d.agent_id = ? AND d.message_id = ?
352
- 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
353
366
  LIMIT 1`)
354
367
  .get(agentId, messageId);
355
368
  if (!row)
@@ -367,14 +380,14 @@ export class MessageStore extends EventEmitter {
367
380
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
368
381
  getThread(agentId, threadId) {
369
382
  const rows = this.db
370
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
371
- m.priority, m.in_reply_to, m.created_at,
372
- MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
373
- FROM deliveries d
374
- JOIN messages m ON m.id = d.message_id
375
- ${THREAD_JOIN}
376
- WHERE d.agent_id = ? AND m.thread_id = ?
377
- 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
378
391
  ORDER BY m.created_at ASC`)
379
392
  .all(agentId, threadId);
380
393
  if (rows.length === 0)
@@ -385,9 +398,9 @@ export class MessageStore extends EventEmitter {
385
398
  /** Pièce jointe d'un message livré à cet agent, contenu compris. */
386
399
  getAttachment(agentId, attachmentId) {
387
400
  const row = this.db
388
- .prepare(`SELECT a.id AS attachment_id, a.message_id, a.filename, a.mime_type, a.size, a.data
389
- FROM attachments a
390
- WHERE a.id = ?
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 = ?
391
404
  AND EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = a.message_id AND d.agent_id = ?)`)
392
405
  .get(attachmentId, agentId);
393
406
  if (!row)
@@ -432,7 +445,7 @@ export class MessageStore extends EventEmitter {
432
445
  // qu'elle ait écrit ou seulement reçu. Les paramètres du WHERE se lient avant ceux du
433
446
  // HAVING : cette condition doit donc être poussée avant celles de la recherche.
434
447
  if (opts.with_agent) {
435
- 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
436
449
  WHERE m2.thread_id = m.thread_id AND d2.agent_id = ?)`);
437
450
  params.push(normalizeAgentId(opts.with_agent));
438
451
  }
@@ -445,20 +458,20 @@ export class MessageStore extends EventEmitter {
445
458
  params.push(like, like, like);
446
459
  }
447
460
  const rows = this.db
448
- .prepare(`SELECT m.thread_id,
449
- t.title, t.description, t.named_by, t.created_at, t.updated_at,
450
- (SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
451
- ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
452
- COUNT(DISTINCT m.id) AS messages,
453
- COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
454
- MAX(m.created_at) AS last_message_at
455
- FROM deliveries d
456
- JOIN messages m ON m.id = d.message_id
457
- ${THREAD_JOIN}
458
- WHERE ${where.join(" AND ")}
459
- GROUP BY m.thread_id
460
- ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
461
- 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
462
475
  LIMIT ? OFFSET ?`)
463
476
  .all(...params, limit, offset);
464
477
  const participants = this.participantsFor(rows.map((r) => r.thread_id));
@@ -481,18 +494,18 @@ export class MessageStore extends EventEmitter {
481
494
  /** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
482
495
  renameThreadRow(threadId, agentId, title, description, now) {
483
496
  this.db
484
- .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
485
- VALUES (?, ?, ?, ?, ?, ?)
486
- ON CONFLICT (id) DO UPDATE SET
487
- title = COALESCE(excluded.title, threads.title),
488
- description = COALESCE(excluded.description, threads.description),
489
- 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,
490
503
  updated_at = excluded.updated_at`)
491
504
  .run(threadId, title, description, agentId, now, now);
492
505
  }
493
506
  assertThreadVisible(agentId, threadId) {
494
507
  const row = this.db
495
- .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
496
509
  WHERE d.agent_id = ? AND m.thread_id = ? LIMIT 1`)
497
510
  .get(agentId, threadId);
498
511
  if (!row)
@@ -505,9 +518,9 @@ export class MessageStore extends EventEmitter {
505
518
  return map;
506
519
  const placeholders = ids.map(() => "?").join(",");
507
520
  const rows = this.db
508
- .prepare(`SELECT DISTINCT m.thread_id, d.agent_id
509
- FROM deliveries d JOIN messages m ON m.id = d.message_id
510
- 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})
511
524
  ORDER BY d.agent_id`)
512
525
  .all(...ids);
513
526
  for (const row of rows) {
@@ -553,7 +566,7 @@ export class MessageStore extends EventEmitter {
553
566
  listFolders(agentId) {
554
567
  const custom = this.db.prepare("SELECT name FROM folders WHERE agent_id = ? ORDER BY name").all(agentId);
555
568
  const counts = this.db
556
- .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
557
570
  FROM deliveries WHERE agent_id = ? GROUP BY folder`)
558
571
  .all(agentId);
559
572
  const byName = new Map(counts.map((c) => [c.folder, c]));
@@ -592,7 +605,7 @@ export class MessageStore extends EventEmitter {
592
605
  if (!exists)
593
606
  throw new MessengerError(`folder not found: ${folder}`, 404);
594
607
  const moved = this.db
595
- .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
596
609
  WHERE agent_id = ? AND folder = ?`)
597
610
  .run(agentId, folder).changes;
598
611
  this.db.prepare("DELETE FROM folders WHERE agent_id = ? AND name = ?").run(agentId, folder);
@@ -625,10 +638,10 @@ export class MessageStore extends EventEmitter {
625
638
  */
626
639
  staleMailboxes(before) {
627
640
  const rows = this.db
628
- .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
629
- (SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
630
- FROM agents a
631
- 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) < ?
632
645
  ORDER BY MAX(${LAST_ACTIVITY}, a.created_at) ASC`)
633
646
  .all(before);
634
647
  return rows.map((row) => ({
@@ -647,10 +660,10 @@ export class MessageStore extends EventEmitter {
647
660
  */
648
661
  mailboxesCreatedBefore(before) {
649
662
  const rows = this.db
650
- .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at,
651
- (SELECT COUNT(*) FROM deliveries d WHERE d.agent_id = a.id) AS messages
652
- FROM agents a
653
- 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 < ?
654
667
  ORDER BY a.created_at ASC`)
655
668
  .all(before);
656
669
  return rows.map((row) => ({
@@ -669,9 +682,9 @@ export class MessageStore extends EventEmitter {
669
682
  */
670
683
  emptyMailboxes() {
671
684
  const rows = this.db
672
- .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at, 0 AS messages
673
- FROM agents a
674
- 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)
675
688
  ORDER BY a.created_at ASC`)
676
689
  .all();
677
690
  return rows.map((row) => ({
@@ -699,9 +712,11 @@ export class MessageStore extends EventEmitter {
699
712
  const dropDeliveries = this.db.prepare("DELETE FROM deliveries WHERE agent_id = ?");
700
713
  const dropFolders = this.db.prepare("DELETE FROM folders WHERE agent_id = ?");
701
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 = ?");
702
716
  for (const id of ids) {
703
717
  deliveries += dropDeliveries.run(id).changes;
704
718
  dropFolders.run(id);
719
+ dropLinks.run(id, id);
705
720
  mailboxes += dropAgent.run(id).changes;
706
721
  }
707
722
  this.dropOrphanMessages();
@@ -715,6 +730,74 @@ export class MessageStore extends EventEmitter {
715
730
  };
716
731
  })();
717
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
+ }
718
801
  // --- Supervision (interface web, lecture seule sauf purge) ---------------
719
802
  /** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
720
803
  listAllMessages(opts = {}) {
@@ -736,12 +819,12 @@ export class MessageStore extends EventEmitter {
736
819
  params.push(opts.thread_id);
737
820
  }
738
821
  const rows = this.db
739
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
740
- m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
741
- FROM messages m
742
- ${THREAD_JOIN}
743
- WHERE ${where.join(" AND ")}
744
- 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
745
828
  LIMIT ? OFFSET ?`)
746
829
  .all(...params, limit, offset);
747
830
  const related = this.relatedFor(rows.map((r) => r.message_id));
@@ -764,7 +847,7 @@ export class MessageStore extends EventEmitter {
764
847
  // reçu : c'est la correspondance qui est filtrée, pas les messages qu'elle a signés.
765
848
  const agents = dedupe((opts.agents ?? []).map((id) => normalizeAgentId(id)).filter(Boolean));
766
849
  if (agents.length > 0) {
767
- 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
768
851
  WHERE mp.thread_id = m.thread_id AND d.agent_id IN (${agents.map(() => "?").join(",")}))`);
769
852
  params.push(...agents);
770
853
  }
@@ -772,8 +855,8 @@ export class MessageStore extends EventEmitter {
772
855
  // n'importe lequel de ses messages. Un fil non nommé reste ainsi trouvable.
773
856
  if (opts.search) {
774
857
  const like = `%${escapeLike(opts.search)}%`;
775
- where.push(`(COALESCE(t.title, '') LIKE ? ESCAPE '\\' OR COALESCE(t.description, '') LIKE ? ESCAPE '\\'
776
- 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
777
860
  AND (ms.subject LIKE ? ESCAPE '\\' OR ms.body LIKE ? ESCAPE '\\')))`);
778
861
  params.push(like, like, like, like);
779
862
  }
@@ -782,28 +865,28 @@ export class MessageStore extends EventEmitter {
782
865
  if (opts.priority)
783
866
  having.push("top_priority = ?");
784
867
  const rows = this.db
785
- .prepare(`SELECT m.thread_id,
786
- t.title, t.description, t.named_by, t.created_at, t.updated_at,
787
- (SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
788
- ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
789
- COUNT(*) AS messages,
790
- MAX(m.created_at) AS last_message_at,
791
- (SELECT r.sender_id FROM messages r WHERE r.thread_id = m.thread_id
792
- ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_message_from,
793
- (SELECT r.body FROM messages r WHERE r.thread_id = m.thread_id
794
- ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_body,
795
- (SELECT COUNT(*) FROM deliveries d JOIN messages mu ON mu.id = d.message_id
796
- WHERE mu.thread_id = m.thread_id AND d.role != 'sender'
797
- AND d.read_at IS NULL AND d.folder != 'trash') AS unread,
798
- CASE WHEN SUM(m.priority = 'high') > 0 THEN 'high'
799
- WHEN SUM(m.priority = 'normal') > 0 THEN 'normal'
800
- ELSE 'low' END AS top_priority
801
- FROM messages m
802
- ${THREAD_JOIN}
803
- WHERE ${where.join(" AND ")}
804
- GROUP BY m.thread_id
805
- ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
806
- 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
807
890
  LIMIT ? OFFSET ?`)
808
891
  .all(...params, ...(opts.priority ? [opts.priority] : []), limit, offset);
809
892
  const participants = this.participantsFor(rows.map((r) => r.thread_id));
@@ -833,11 +916,11 @@ export class MessageStore extends EventEmitter {
833
916
  */
834
917
  listThreadAdmin(threadId) {
835
918
  const rows = this.db
836
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
837
- m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
838
- FROM messages m
839
- ${THREAD_JOIN}
840
- 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 = ?
841
924
  ORDER BY m.created_at ASC, m.id ASC`)
842
925
  .all(threadId);
843
926
  const related = this.relatedFor(rows.map((r) => r.message_id));
@@ -846,8 +929,8 @@ export class MessageStore extends EventEmitter {
846
929
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
847
930
  getMessageAdmin(messageId) {
848
931
  const row = this.db
849
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
850
- 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}
851
934
  FROM messages m ${THREAD_JOIN} WHERE m.id = ?`)
852
935
  .get(messageId);
853
936
  if (!row)
@@ -869,7 +952,7 @@ export class MessageStore extends EventEmitter {
869
952
  /** Pièce jointe vue du serveur, quelle que soit la boîte. */
870
953
  getAttachmentAdmin(attachmentId) {
871
954
  const row = this.db
872
- .prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size, data
955
+ .prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size, data
873
956
  FROM attachments WHERE id = ?`)
874
957
  .get(attachmentId);
875
958
  if (!row)
@@ -881,9 +964,9 @@ export class MessageStore extends EventEmitter {
881
964
  const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
882
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]));
883
966
  const receivedByAgent = new Map(this.db
884
- .prepare(`SELECT agent_id,
885
- COUNT(*) AS received,
886
- 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
887
970
  FROM deliveries WHERE role != 'sender' GROUP BY agent_id`)
888
971
  .all().map((r) => [r.agent_id, r]));
889
972
  const agents = this.listAgents().map((agent) => ({
@@ -930,14 +1013,14 @@ export class MessageStore extends EventEmitter {
930
1013
  return related;
931
1014
  const placeholders = ids.map(() => "?").join(",");
932
1015
  const recipients = this.db
933
- .prepare(`SELECT message_id, agent_id FROM deliveries
1016
+ .prepare(`SELECT message_id, agent_id FROM deliveries
934
1017
  WHERE message_id IN (${placeholders}) AND role = 'to'`)
935
1018
  .all(...ids);
936
1019
  for (const row of recipients)
937
1020
  related.recipients.set(row.message_id, row.agent_id);
938
1021
  const attachments = this.db
939
- .prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size FROM attachments
940
- WHERE message_id IN (${placeholders})
1022
+ .prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size FROM attachments
1023
+ WHERE message_id IN (${placeholders})
941
1024
  ORDER BY message_id, position`)
942
1025
  .all(...ids);
943
1026
  for (const { message_id, ...info } of attachments) {
@@ -975,6 +1058,17 @@ export class MessageStore extends EventEmitter {
975
1058
  this.db.exec("DELETE FROM threads WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.thread_id = threads.id)");
976
1059
  }
977
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
+ }
978
1072
  export function normalizeAgentId(id) {
979
1073
  return String(id ?? "").trim();
980
1074
  }