@imenam/mcp-messenger 1.0.29 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,10 +211,11 @@ description dit « mailbox address ».
211
211
  | `rename_thread` | Nommer ou renommer un fil (`thread_id`) ; le nom est partagé par tous ses participants |
212
212
  | `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité ; `with_mailbox` ne garde que celles partagées avec une boîte |
213
213
  | `list_agents` | Annuaire groupé par agent : un projet, ses sessions ; `agent_name` déplie un groupe. La boîte de l'appelant n'y figure pas |
214
- | `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`) |
214
+ | `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`, `attachments`) |
215
215
  | `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
216
- | `read_message` | Lire un message en entier ; le marque lu par défaut |
216
+ | `read_message` | Lire un message en entier, pièces jointes listées ; le marque lu par défaut |
217
217
  | `get_thread` | Relire toute une conversation, du plus ancien au plus récent |
218
+ | `save_attachment` | Écrire une pièce jointe sur le disque de la machine (`attachment_id`, `path`, `overwrite`) et rendre son chemin |
218
219
  | `mark_messages` | Marquer lu / non lu, suivi / non suivi |
219
220
  | `move_messages` | Classer dans un dossier |
220
221
  | `delete_messages` | Corbeille, ou suppression définitive (`permanent: true`) |
@@ -231,6 +232,16 @@ elle-même. Pour toucher plusieurs agents, on leur envoie un message chacun, dan
231
232
  fil. C'est aussi pourquoi `list_agents` ne montre jamais l'appelant : la seule adresse qu'il
232
233
  ne peut pas viser n'a pas à figurer dans son annuaire.
233
234
 
235
+ **Pièces jointes** — `send_message` accepte `attachments: [{ path, name? }]` : le processus
236
+ MCP lit chaque fichier sur la machine de l'agent qui écrit et l'envoie avec le message.
237
+ Au plus 10 fichiers, 10 Mo chacun et 25 Mo au total ; un fichier absent ou trop gros fait
238
+ échouer l'envoi. Le type MIME est déduit de l'extension, `name` renomme le fichier pour le
239
+ destinataire. Celui-ci voit la liste sous le message (`att_…`, nom, type, taille) et
240
+ récupère un fichier avec `save_attachment` : dans le répertoire donné sous son propre nom,
241
+ au chemin de fichier donné, ou par défaut dans le dossier temporaire du système. Un fichier
242
+ existant n'est remplacé qu'avec `overwrite: true`. Les pièces jointes sont stockées dans la
243
+ base avec le message et disparaissent avec sa dernière copie.
244
+
234
245
  **Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
235
246
  `trash`. Les dossiers personnalisés sont propres à chaque boîte.
236
247
 
@@ -463,7 +474,9 @@ correspondants et l'état de lecture :
463
474
  écrit ne peut devenir une balise, et seuls les liens `http`, `https` et `mailto` sont
464
475
  suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
465
476
  Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
466
- ligne, où un dièse ou une paire d'astérisques ne serait que du bruit ;
477
+ ligne, où un dièse ou une paire d'astérisques ne serait que du bruit. **Les pièces
478
+ jointes** suivent le corps d'un message ouvert, une par lien, avec leur nom et leur
479
+ taille ; un clic les télécharge, jamais elles ne s'ouvrent dans la console ;
467
480
  - **les détails d'un message**, dans une modale ouverte par l'icône d'information au bout
468
481
  de sa ligne : l'objet, le nom et l'identifiant du fil, les identifiants, la date, la
469
482
  priorité, les deux noms et l'adresse de chaque boîte, et le tableau de distribution
@@ -591,12 +604,13 @@ la console (cookie), qui elle n'ouvre rien d'autre.
591
604
  | `GET` | `/api/agents/:id` | Une boîte, ou 404 |
592
605
  | `PATCH` | `/api/agents/me` | Renommer la boîte courante |
593
606
  | `GET` | `/api/mailbox` | Compteurs de la boîte, et présence d'un veilleur (`watcher`) |
594
- | `POST` | `/api/messages` | Envoyer un message |
607
+ | `POST` | `/api/messages` | Envoyer un message ; `attachments: [{ filename, mime_type?, content_base64 }]` |
595
608
  | `GET` | `/api/messages` | Lister (`folder`, `unread_only`, `from`, `thread_id`, `search`, `limit`, `offset`) |
596
609
  | `GET` | `/api/messages/:id` | Lire (`?mark_read=false` pour ne pas marquer) |
597
610
  | `POST` | `/api/messages/mark` | Marquer lu / suivi |
598
611
  | `POST` | `/api/messages/move` | Déplacer vers un dossier |
599
612
  | `POST` | `/api/messages/delete` | Corbeille ou suppression définitive |
613
+ | `GET` | `/api/attachments/:id` | Contenu d'une pièce jointe d'un message de la boîte, en téléchargement |
600
614
  | `GET` | `/api/threads` | Fils de la boîte (`unread_only`, `search`, `limit`, `offset`) |
601
615
  | `GET` | `/api/threads/:id` | Fil complet, avec son intitulé |
602
616
  | `PATCH` | `/api/threads/:id` | Nommer le fil (`title`, `description`) — participants uniquement |
@@ -605,6 +619,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
605
619
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
606
620
  | `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
607
621
  | `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
622
+ | `GET` | `/admin/attachments/:id` | Contenu d'une pièce jointe, en téléchargement |
608
623
  | `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
609
624
  | `GET` | `/admin/mailboxes/created-before` | Boîtes ouvertes avant `created_before`, quelle que soit leur activité |
610
625
  | `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
@@ -625,8 +640,12 @@ la console (cookie), qui elle n'ouvre rien d'autre.
625
640
  d'emprunter l'identité d'un autre agent. À préférer au jeton partagé dès que les agents
626
641
  ne se font pas mutuellement confiance.
627
642
  - Comparaison des jetons à **temps constant**, en-têtes de sécurité (`helmet`), **rate
628
- limiting** par IP, corps JSON limité à 2 Mo, erreurs internes génériques (le détail reste
629
- dans les logs).
643
+ limiting** par IP, corps JSON limité à 2 Mo — sauf l'envoi d'un message, dont la limite
644
+ couvre ses pièces jointes et ne s'applique qu'une fois l'agent authentifié —, erreurs
645
+ internes génériques (le détail reste dans les logs).
646
+ - **Pièces jointes** : servies uniquement aux deux boîtes du message (et à la console),
647
+ toujours en `Content-Disposition: attachment`, jamais affichées en ligne ; leur nom est
648
+ réduit à sa dernière composante, il ne peut désigner aucun autre répertoire.
630
649
  - **Isolation des boîtes** garantie côté serveur : les identifiants de messages fournis par
631
650
  un agent sont systématiquement filtrés sur ses propres livraisons.
632
651
  - La console web n'insère jamais de HTML fourni par un agent : le corps des messages est
@@ -665,7 +684,8 @@ src/
665
684
  index.ts # point d'entrée : --server, --watch, --help, --version, sinon mode MCP
666
685
  config.ts # lecture et validation des variables d'environnement
667
686
  logger.ts # journalisation sur stderr (stdout est réservé au JSON-RPC)
668
- core/store.ts # SQLite : agents, messages, livraisons, dossiers
687
+ core/store.ts # SQLite : agents, messages, livraisons, dossiers, pièces jointes
688
+ core/attachments.ts # limites des pièces jointes, en-tête de téléchargement
669
689
  server/app.ts # API HTTP, authentification, supervision
670
690
  server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
671
691
  server/start.ts # démarrage, arrêt propre, purge de la corbeille
@@ -5,6 +5,17 @@ export declare class MessengerClientError extends Error {
5
5
  readonly status?: number | undefined;
6
6
  constructor(message: string, status?: number | undefined);
7
7
  }
8
+ /** Pièce jointe à l'envoi, contenu encodé en base64. */
9
+ export interface OutgoingAttachment {
10
+ filename: string;
11
+ mime_type: string | null;
12
+ content_base64: string;
13
+ }
14
+ export interface DownloadedAttachment {
15
+ filename: string;
16
+ mime_type: string;
17
+ data: Buffer;
18
+ }
8
19
  export interface WaitResult {
9
20
  timed_out: boolean;
10
21
  message: Message | null;
@@ -68,9 +79,11 @@ export declare class MessengerClient {
68
79
  reply_to?: string | null;
69
80
  thread_title?: string | null;
70
81
  thread_description?: string | null;
82
+ attachments?: OutgoingAttachment[];
71
83
  }): Promise<SendMessageResult>;
72
84
  listMessages(options?: ListMessagesOptions): Promise<MessageSummary[]>;
73
85
  getMessage(messageId: string, markRead?: boolean): Promise<Message>;
86
+ downloadAttachment(attachmentId: string): Promise<DownloadedAttachment>;
74
87
  markMessages(messageIds: string[], state: {
75
88
  read?: boolean;
76
89
  starred?: boolean;
@@ -104,4 +117,7 @@ export declare class MessengerClient {
104
117
  watcher?: boolean;
105
118
  }): Promise<WaitResult>;
106
119
  private request;
120
+ private send;
121
+ /** Erreur lisible tirée d'une réponse en échec. */
122
+ private failure;
107
123
  }
@@ -1,3 +1,4 @@
1
+ import { filenameFromDisposition } from "../core/attachments.js";
1
2
  /** Échec d'un appel au serveur boîte aux lettres (réseau, authentification ou métier). */
2
3
  export class MessengerClientError extends Error {
3
4
  status;
@@ -105,6 +106,16 @@ export class MessengerClient {
105
106
  query: { mark_read: markRead },
106
107
  });
107
108
  }
109
+ async downloadAttachment(attachmentId) {
110
+ const response = await this.send("GET", `/api/attachments/${encodeURIComponent(attachmentId)}`);
111
+ if (!response.ok)
112
+ throw await this.failure(response);
113
+ return {
114
+ filename: filenameFromDisposition(response.headers.get("content-disposition")) ?? attachmentId,
115
+ mime_type: response.headers.get("content-type") ?? "application/octet-stream",
116
+ data: Buffer.from(await response.arrayBuffer()),
117
+ };
118
+ }
108
119
  async markMessages(messageIds, state) {
109
120
  const { updated } = await this.request("POST", "/api/messages/mark", {
110
121
  body: { message_ids: messageIds, ...state },
@@ -160,6 +171,13 @@ export class MessengerClient {
160
171
  });
161
172
  }
162
173
  async request(method, routePath, options = {}) {
174
+ const response = await this.send(method, routePath, options);
175
+ if (!response.ok)
176
+ throw await this.failure(response);
177
+ const text = await response.text();
178
+ return (text ? safeParse(text) : null);
179
+ }
180
+ async send(method, routePath, options = {}) {
163
181
  const url = new URL(this.config.serverUrl + routePath);
164
182
  for (const [key, value] of Object.entries(options.query ?? {})) {
165
183
  if (value === undefined || value === null || value === "")
@@ -171,9 +189,8 @@ export class MessengerClient {
171
189
  headers.authorization = `Bearer ${this.config.token}`;
172
190
  if (options.body !== undefined)
173
191
  headers["content-type"] = "application/json";
174
- let response;
175
192
  try {
176
- response = await fetch(url, {
193
+ return await fetch(url, {
177
194
  method,
178
195
  headers,
179
196
  body: options.body === undefined ? undefined : JSON.stringify(options.body),
@@ -184,15 +201,15 @@ export class MessengerClient {
184
201
  const reason = error instanceof Error ? error.message : String(error);
185
202
  throw new MessengerClientError(`cannot reach the messenger server at ${this.config.serverUrl}: ${reason}`);
186
203
  }
204
+ }
205
+ /** Erreur lisible tirée d'une réponse en échec. */
206
+ async failure(response) {
187
207
  const text = await response.text();
188
208
  const payload = text ? safeParse(text) : null;
189
- if (!response.ok) {
190
- const detail = payload && typeof payload === "object" && "error" in payload
191
- ? String(payload.error)
192
- : text.slice(0, 300) || response.statusText;
193
- throw new MessengerClientError(detail, response.status);
194
- }
195
- return payload;
209
+ const detail = payload && typeof payload === "object" && "error" in payload
210
+ ? String(payload.error)
211
+ : text.slice(0, 300) || response.statusText;
212
+ return new MessengerClientError(detail, response.status);
196
213
  }
197
214
  }
198
215
  function safeParse(text) {
@@ -0,0 +1,16 @@
1
+ /** Nombre de pièces jointes par message. */
2
+ export declare const MAX_ATTACHMENTS = 10;
3
+ /** Taille d'une pièce jointe, en octets. */
4
+ export declare const MAX_ATTACHMENT_BYTES: number;
5
+ /** Taille cumulée des pièces jointes d'un message, en octets. */
6
+ export declare const MAX_ATTACHMENTS_TOTAL_BYTES: number;
7
+ export declare function formatBytes(bytes: number): string;
8
+ /**
9
+ * En-tête `Content-Disposition` d'un téléchargement.
10
+ *
11
+ * Toujours `attachment` : servi en ligne, un fichier HTML ou SVG s'exécuterait dans
12
+ * l'origine de la console.
13
+ */
14
+ export declare function contentDisposition(filename: string): string;
15
+ /** Nom de fichier porté par un en-tête produit par `contentDisposition`. */
16
+ export declare function filenameFromDisposition(header: string | null): string | null;
@@ -0,0 +1,28 @@
1
+ /** Nombre de pièces jointes par message. */
2
+ export const MAX_ATTACHMENTS = 10;
3
+ /** Taille d'une pièce jointe, en octets. */
4
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
5
+ /** Taille cumulée des pièces jointes d'un message, en octets. */
6
+ export const MAX_ATTACHMENTS_TOTAL_BYTES = 25 * 1024 * 1024;
7
+ export function formatBytes(bytes) {
8
+ if (bytes < 1024)
9
+ return `${bytes} B`;
10
+ if (bytes < 1024 * 1024)
11
+ return `${(bytes / 1024).toFixed(1)} KB`;
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ }
14
+ /**
15
+ * En-tête `Content-Disposition` d'un téléchargement.
16
+ *
17
+ * Toujours `attachment` : servi en ligne, un fichier HTML ou SVG s'exécuterait dans
18
+ * l'origine de la console.
19
+ */
20
+ export function contentDisposition(filename) {
21
+ const ascii = filename.replace(/[^\x20-\x7e]|["\\]/g, "_");
22
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
23
+ }
24
+ /** Nom de fichier porté par un en-tête produit par `contentDisposition`. */
25
+ export function filenameFromDisposition(header) {
26
+ const encoded = header?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
27
+ return encoded ? decodeURIComponent(encoded) : null;
28
+ }
@@ -1,10 +1,15 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { type AdminThreadSummary, type Agent, type AgentDirectoryEntry, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type Priority, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
2
+ import { type AdminThreadSummary, type Agent, type AgentDirectoryEntry, type AttachmentInfo, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type Priority, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
3
3
  /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
4
4
  export declare class MessengerError extends Error {
5
5
  readonly status: number;
6
6
  constructor(message: string, status?: number);
7
7
  }
8
+ /** Contenu d'une pièce jointe, avec ses métadonnées. */
9
+ export interface AttachmentContent extends AttachmentInfo {
10
+ message_id: string;
11
+ data: Buffer;
12
+ }
8
13
  export interface EnsureAgentOptions {
9
14
  registered?: boolean;
10
15
  /** Identité de l'agent : d'où il vient. Posée par la configuration. */
@@ -55,6 +60,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
55
60
  getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
56
61
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
57
62
  getThread(agentId: string, threadId: string): Message[];
63
+ /** Pièce jointe d'un message livré à cet agent, contenu compris. */
64
+ getAttachment(agentId: string, attachmentId: string): AttachmentContent;
58
65
  getThreadInfo(threadId: string): ThreadInfo | null;
59
66
  /**
60
67
  * Renomme un fil. Le titre est unique et partagé : les participants parlent du même
@@ -183,6 +190,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
183
190
  read: boolean;
184
191
  }[];
185
192
  };
193
+ /** Pièce jointe vue du serveur, quelle que soit la boîte. */
194
+ getAttachmentAdmin(attachmentId: string): AttachmentContent;
186
195
  overview(): {
187
196
  messages: number;
188
197
  threads: number;
@@ -196,8 +205,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
196
205
  /** Filtre les ids fournis sur ceux réellement livrés à cet agent (isolation des boîtes). */
197
206
  private ownedMessageIds;
198
207
  private assertFolderExists;
199
- /** Destinataire de chaque message demandé, par identifiant de message. */
200
- private recipientsFor;
208
+ /** Destinataire et pièces jointes de chaque message demandé, par identifiant de message. */
209
+ private relatedFor;
201
210
  private toSummary;
202
211
  private toMessage;
203
212
  /** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
@@ -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,6 +19,9 @@ 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";
21
25
  /**
22
26
  * Dernière activité d'une boîte : sa dernière connexion, ou le dernier message qui l'a
23
27
  * touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
@@ -115,6 +119,18 @@ export class MessageStore extends EventEmitter {
115
119
  PRIMARY KEY (agent_id, name)
116
120
  );
117
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);
118
134
  CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
119
135
  CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
120
136
  CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
@@ -223,6 +239,7 @@ export class MessageStore extends EventEmitter {
223
239
  throw new MessengerError("an agent cannot send a message to itself");
224
240
  const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
225
241
  const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
242
+ const attachments = normalizeAttachments(input.attachments ?? []);
226
243
  let threadId;
227
244
  let inReplyTo = null;
228
245
  if (input.reply_to) {
@@ -262,6 +279,11 @@ export class MessageStore extends EventEmitter {
262
279
  .prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
263
280
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
264
281
  .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)
283
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
284
+ attachments.forEach((a, position) => {
285
+ insertAttachment.run(newId("att"), messageId, position, a.filename, a.mime_type, a.data.length, a.data, now);
286
+ });
265
287
  const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
266
288
  VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
267
289
  for (const d of deliveries) {
@@ -316,8 +338,8 @@ export class MessageStore extends EventEmitter {
316
338
  ORDER BY m.created_at DESC, m.id DESC
317
339
  LIMIT ? OFFSET ?`)
318
340
  .all(...params, limit, offset);
319
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
320
- return rows.map((row) => this.toSummary(row, recipients));
341
+ const related = this.relatedFor(rows.map((r) => r.message_id));
342
+ return rows.map((row) => this.toSummary(row, related));
321
343
  }
322
344
  getMessage(agentId, messageId, markRead = false) {
323
345
  const row = this.db
@@ -339,8 +361,8 @@ export class MessageStore extends EventEmitter {
339
361
  .run(now, agentId, messageId);
340
362
  row.read_at = now;
341
363
  }
342
- const recipients = this.recipientsFor([messageId]);
343
- return this.toMessage(row, recipients);
364
+ const related = this.relatedFor([messageId]);
365
+ return this.toMessage(row, related);
344
366
  }
345
367
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
346
368
  getThread(agentId, threadId) {
@@ -357,8 +379,20 @@ export class MessageStore extends EventEmitter {
357
379
  .all(agentId, threadId);
358
380
  if (rows.length === 0)
359
381
  throw new MessengerError(`thread not found in this mailbox: ${threadId}`, 404);
360
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
361
- return rows.map((row) => this.toMessage(row, recipients));
382
+ const related = this.relatedFor(rows.map((r) => r.message_id));
383
+ return rows.map((row) => this.toMessage(row, related));
384
+ }
385
+ /** Pièce jointe d'un message livré à cet agent, contenu compris. */
386
+ getAttachment(agentId, attachmentId) {
387
+ 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 = ?
391
+ AND EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = a.message_id AND d.agent_id = ?)`)
392
+ .get(attachmentId, agentId);
393
+ if (!row)
394
+ throw new MessengerError(`attachment not found in this mailbox: ${attachmentId}`, 404);
395
+ return row;
362
396
  }
363
397
  // --- Fils ---------------------------------------------------------------
364
398
  getThreadInfo(threadId) {
@@ -710,8 +744,8 @@ export class MessageStore extends EventEmitter {
710
744
  ORDER BY m.created_at DESC, m.id DESC
711
745
  LIMIT ? OFFSET ?`)
712
746
  .all(...params, limit, offset);
713
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
714
- return rows.map((row) => this.toSummary(row, recipients));
747
+ const related = this.relatedFor(rows.map((r) => r.message_id));
748
+ return rows.map((row) => this.toSummary(row, related));
715
749
  }
716
750
  /**
717
751
  * Fils du serveur entier, le plus récemment actif en tête.
@@ -806,8 +840,8 @@ export class MessageStore extends EventEmitter {
806
840
  WHERE m.thread_id = ?
807
841
  ORDER BY m.created_at ASC, m.id ASC`)
808
842
  .all(threadId);
809
- const recipients = this.recipientsFor(rows.map((r) => r.message_id));
810
- return rows.map((row) => ({ ...this.toSummary(row, recipients), body: row.body }));
843
+ const related = this.relatedFor(rows.map((r) => r.message_id));
844
+ return rows.map((row) => ({ ...this.toSummary(row, related), body: row.body }));
811
845
  }
812
846
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
813
847
  getMessageAdmin(messageId) {
@@ -818,12 +852,12 @@ export class MessageStore extends EventEmitter {
818
852
  .get(messageId);
819
853
  if (!row)
820
854
  throw new MessengerError(`message not found: ${messageId}`, 404);
821
- const recipients = this.recipientsFor([messageId]);
855
+ const related = this.relatedFor([messageId]);
822
856
  const deliveries = this.db
823
857
  .prepare("SELECT agent_id, role, folder, read_at FROM deliveries WHERE message_id = ? ORDER BY role, agent_id")
824
858
  .all(messageId);
825
859
  return {
826
- ...this.toMessage(row, recipients),
860
+ ...this.toMessage(row, related),
827
861
  deliveries: deliveries.map((d) => ({
828
862
  agent_id: d.agent_id,
829
863
  role: d.role,
@@ -832,6 +866,16 @@ export class MessageStore extends EventEmitter {
832
866
  })),
833
867
  };
834
868
  }
869
+ /** Pièce jointe vue du serveur, quelle que soit la boîte. */
870
+ getAttachmentAdmin(attachmentId) {
871
+ const row = this.db
872
+ .prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size, data
873
+ FROM attachments WHERE id = ?`)
874
+ .get(attachmentId);
875
+ if (!row)
876
+ throw new MessengerError(`attachment not found: ${attachmentId}`, 404);
877
+ return row;
878
+ }
835
879
  overview() {
836
880
  const messages = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
837
881
  const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
@@ -878,28 +922,36 @@ export class MessageStore extends EventEmitter {
878
922
  if (!row)
879
923
  throw new MessengerError(`unknown folder: ${folder}`, 404);
880
924
  }
881
- /** Destinataire de chaque message demandé, par identifiant de message. */
882
- recipientsFor(messageIds) {
883
- const map = new Map();
925
+ /** Destinataire et pièces jointes de chaque message demandé, par identifiant de message. */
926
+ relatedFor(messageIds) {
927
+ const related = { recipients: new Map(), attachments: new Map() };
884
928
  const ids = dedupe(messageIds);
885
929
  if (ids.length === 0)
886
- return map;
930
+ return related;
887
931
  const placeholders = ids.map(() => "?").join(",");
888
- const rows = this.db
932
+ const recipients = this.db
889
933
  .prepare(`SELECT message_id, agent_id FROM deliveries
890
934
  WHERE message_id IN (${placeholders}) AND role = 'to'`)
891
935
  .all(...ids);
892
- for (const row of rows)
893
- map.set(row.message_id, row.agent_id);
894
- return map;
936
+ for (const row of recipients)
937
+ related.recipients.set(row.message_id, row.agent_id);
938
+ 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})
941
+ ORDER BY message_id, position`)
942
+ .all(...ids);
943
+ for (const { message_id, ...info } of attachments) {
944
+ related.attachments.set(message_id, [...(related.attachments.get(message_id) ?? []), info]);
945
+ }
946
+ return related;
895
947
  }
896
- toSummary(row, recipients) {
948
+ toSummary(row, related) {
897
949
  return {
898
950
  message_id: row.message_id,
899
951
  thread_id: row.thread_id,
900
952
  thread_title: row.thread_title,
901
953
  from: row.sender_id,
902
- to: recipients.get(row.message_id) ?? "",
954
+ to: related.recipients.get(row.message_id) ?? "",
903
955
  subject: row.subject,
904
956
  preview: preview(row.body),
905
957
  priority: row.priority,
@@ -909,10 +961,11 @@ export class MessageStore extends EventEmitter {
909
961
  starred: Boolean(row.starred),
910
962
  created_at: row.created_at,
911
963
  in_reply_to: row.in_reply_to,
964
+ attachments: related.attachments.get(row.message_id) ?? [],
912
965
  };
913
966
  }
914
- toMessage(row, recipients) {
915
- const { preview: _preview, ...summary } = this.toSummary(row, recipients);
967
+ toMessage(row, related) {
968
+ const { preview: _preview, ...summary } = this.toSummary(row, related);
916
969
  return { ...summary, body: row.body };
917
970
  }
918
971
  /** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
@@ -941,6 +994,47 @@ function normalizeLabel(value, max, field) {
941
994
  throw new MessengerError(`${field} must be at most ${max} characters`);
942
995
  return trimmed;
943
996
  }
997
+ /**
998
+ * Valide les pièces jointes d'un envoi : nombre, tailles, nom et type.
999
+ *
1000
+ * Le nom ne garde que sa dernière composante : il sert à nommer un fichier chez le
1001
+ * destinataire, et ne doit jamais désigner un autre répertoire que celui qu'il choisit.
1002
+ */
1003
+ function normalizeAttachments(attachments) {
1004
+ if (attachments.length > MAX_ATTACHMENTS) {
1005
+ throw new MessengerError(`at most ${MAX_ATTACHMENTS} attachments per message`);
1006
+ }
1007
+ let total = 0;
1008
+ const normalized = attachments.map((attachment) => {
1009
+ const filename = sanitizeFilename(attachment.filename);
1010
+ if (attachment.data.length === 0)
1011
+ throw new MessengerError(`attachment is empty: ${filename}`);
1012
+ if (attachment.data.length > MAX_ATTACHMENT_BYTES) {
1013
+ throw new MessengerError(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${filename}`, 413);
1014
+ }
1015
+ total += attachment.data.length;
1016
+ const mimeType = (attachment.mime_type ?? "").trim().toLowerCase() || DEFAULT_MIME_TYPE;
1017
+ if (!MIME_TYPE_RE.test(mimeType))
1018
+ throw new MessengerError(`invalid mime_type for ${filename}: ${mimeType}`);
1019
+ return { filename, mime_type: mimeType, data: attachment.data };
1020
+ });
1021
+ if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
1022
+ throw new MessengerError(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`, 413);
1023
+ }
1024
+ return normalized;
1025
+ }
1026
+ function sanitizeFilename(value) {
1027
+ const base = String(value ?? "")
1028
+ .split(/[\\/]/)
1029
+ .pop()
1030
+ .replace(/[\u0000-\u001f\u007f<>:"|?*]/g, "_")
1031
+ .trim();
1032
+ if (!base || base === "." || base === "..")
1033
+ throw new MessengerError("attachment filename cannot be empty");
1034
+ if (base.length > FILENAME_MAX)
1035
+ throw new MessengerError(`attachment filename must be at most ${FILENAME_MAX} characters`);
1036
+ return base;
1037
+ }
944
1038
  function dedupe(values) {
945
1039
  return [...new Set(values)];
946
1040
  }
@@ -35,6 +35,21 @@ export interface AgentDirectoryEntry extends Agent {
35
35
  /** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
36
36
  last_activity_at: string | null;
37
37
  }
38
+ /** Pièce jointe telle qu'elle voyage avec un message : ses métadonnées, sans son contenu. */
39
+ export interface AttachmentInfo {
40
+ attachment_id: string;
41
+ filename: string;
42
+ mime_type: string;
43
+ /** Taille en octets. */
44
+ size: number;
45
+ }
46
+ /** Pièce jointe fournie à l'envoi. */
47
+ export interface AttachmentUpload {
48
+ filename: string;
49
+ /** Type MIME ; `application/octet-stream` s'il est absent. */
50
+ mime_type?: string | null;
51
+ data: Buffer;
52
+ }
38
53
  export interface SendMessageInput {
39
54
  from: string;
40
55
  /** Un échange lie exactement deux boîtes : un seul destinataire par message. */
@@ -47,6 +62,7 @@ export interface SendMessageInput {
47
62
  /** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
48
63
  thread_title?: string | null;
49
64
  thread_description?: string | null;
65
+ attachments?: AttachmentUpload[];
50
66
  }
51
67
  /**
52
68
  * Intitulé d'un fil de discussion.
@@ -98,6 +114,7 @@ export interface MessageSummary {
98
114
  starred: boolean;
99
115
  created_at: string;
100
116
  in_reply_to: string | null;
117
+ attachments: AttachmentInfo[];
101
118
  }
102
119
  export interface Message extends Omit<MessageSummary, "preview"> {
103
120
  body: string;
@@ -1,5 +1,9 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { z } from "zod";
2
5
  import { writeHandoff } from "../cli/watch-handoff.js";
6
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "../core/attachments.js";
3
7
  import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
4
8
  import { rebind } from "./mailbox-registry.js";
5
9
  import { forgetCandidate, getResumeState } from "./resume-state.js";
@@ -7,6 +11,35 @@ import { forgetCandidate, getResumeState } from "./resume-state.js";
7
11
  const PACKAGE_NAME = "@imenam/mcp-messenger";
8
12
  /** Durée maximale d'un `Monitor` côté hôte : au-delà, l'hôte la ramène à cette valeur. */
9
13
  const MONITOR_TIMEOUT_MS = 1_800_000;
14
+ /** Répertoire où `save_attachment` écrit quand l'agent ne donne pas de destination. */
15
+ const DEFAULT_ATTACHMENT_DIR = path.join(os.tmpdir(), "mcp-messenger", "attachments");
16
+ /** Type MIME déduit de l'extension, pour les formats courants. */
17
+ const MIME_TYPES = {
18
+ ".txt": "text/plain",
19
+ ".md": "text/markdown",
20
+ ".csv": "text/csv",
21
+ ".json": "application/json",
22
+ ".xml": "application/xml",
23
+ ".yaml": "application/yaml",
24
+ ".yml": "application/yaml",
25
+ ".html": "text/html",
26
+ ".css": "text/css",
27
+ ".js": "text/javascript",
28
+ ".log": "text/plain",
29
+ ".pdf": "application/pdf",
30
+ ".png": "image/png",
31
+ ".jpg": "image/jpeg",
32
+ ".jpeg": "image/jpeg",
33
+ ".gif": "image/gif",
34
+ ".webp": "image/webp",
35
+ ".svg": "image/svg+xml",
36
+ ".zip": "application/zip",
37
+ ".gz": "application/gzip",
38
+ ".tar": "application/x-tar",
39
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
41
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
42
+ };
10
43
  const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
11
44
  export const TOOLS = [
12
45
  {
@@ -107,6 +140,23 @@ export const TOOLS = [
107
140
  description: "Plain-language name of the conversation, e.g. 'Résolution de l'échec du test de parsing'. Name the thread when you open it — you already know what it is about. On a reply it renames the thread for everyone (same effect as rename_thread).",
108
141
  },
109
142
  thread_description: { type: "string", description: "Optional longer description of the thread." },
143
+ attachments: {
144
+ type: "array",
145
+ maxItems: MAX_ATTACHMENTS,
146
+ description: `Files to attach, read from this machine's disk. At most ${MAX_ATTACHMENTS} files, ${formatBytes(MAX_ATTACHMENT_BYTES)} each and ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total. The recipient saves them with save_attachment.`,
147
+ items: {
148
+ type: "object",
149
+ properties: {
150
+ path: {
151
+ type: "string",
152
+ description: "Path of the file to attach. Prefer an absolute path; a relative one is resolved against this MCP server's working directory.",
153
+ },
154
+ name: { type: "string", description: "File name shown to the recipient. Defaults to the file's own name." },
155
+ },
156
+ required: ["path"],
157
+ additionalProperties: false,
158
+ },
159
+ },
110
160
  },
111
161
  required: ["to", "subject", "body"],
112
162
  additionalProperties: false,
@@ -152,6 +202,23 @@ export const TOOLS = [
152
202
  additionalProperties: false,
153
203
  },
154
204
  },
205
+ {
206
+ name: "save_attachment",
207
+ description: "Save a file attached to a message of this mailbox onto this machine's disk, and return the path it was written to. Attachment ids are listed under the message by read_message and get_thread. Read the saved file with your usual file tools.",
208
+ inputSchema: {
209
+ type: "object",
210
+ properties: {
211
+ attachment_id: { type: "string", description: "Attachment id (att_…) shown under the message." },
212
+ path: {
213
+ type: "string",
214
+ description: "Where to save it: an existing directory (the file keeps its name) or a file path. Defaults to a directory under the system temp folder.",
215
+ },
216
+ overwrite: { type: "boolean", description: "Replace an existing file at that path. Defaults to false." },
217
+ },
218
+ required: ["attachment_id"],
219
+ additionalProperties: false,
220
+ },
221
+ },
155
222
  {
156
223
  name: "mark_messages",
157
224
  description: "Mark messages as read or unread, and/or star or unstar them.",
@@ -244,6 +311,15 @@ const schemas = {
244
311
  reply_to: z.string().optional(),
245
312
  thread_name: z.string().trim().min(1).max(200).optional(),
246
313
  thread_description: z.string().trim().min(1).max(2000).optional(),
314
+ attachments: z
315
+ .array(z.object({ path: z.string().trim().min(1), name: z.string().trim().min(1).max(255).optional() }))
316
+ .max(MAX_ATTACHMENTS)
317
+ .optional(),
318
+ }),
319
+ save_attachment: z.object({
320
+ attachment_id: z.string().trim().min(1),
321
+ path: z.string().trim().min(1).optional(),
322
+ overwrite: z.boolean().optional(),
247
323
  }),
248
324
  list_messages: z.object({
249
325
  folder: z.string().optional(),
@@ -425,6 +501,7 @@ export async function callTool(client, name, rawArgs) {
425
501
  }
426
502
  case "send_message": {
427
503
  const input = schemas.send_message.parse(args);
504
+ const attachments = await readAttachments(input.attachments ?? []);
428
505
  const result = await client.sendMessage({
429
506
  to: input.to,
430
507
  subject: input.subject,
@@ -434,12 +511,16 @@ export async function callTool(client, name, rawArgs) {
434
511
  // Le protocole HTTP et le stockage gardent `thread_title` ; seul l'outil dit `thread_name`.
435
512
  thread_title: input.thread_name ?? null,
436
513
  thread_description: input.thread_description ?? null,
514
+ attachments,
437
515
  });
438
516
  const lines = [
439
517
  `Message sent: ${result.message_id}`,
440
518
  `Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
441
519
  `Delivered to: ${result.delivered_to}`,
442
520
  ];
521
+ if (attachments.length > 0) {
522
+ lines.push(`Attachments: ${attachments.map((a) => a.filename).join(", ")}`);
523
+ }
443
524
  if (!result.thread_title && !input.reply_to) {
444
525
  lines.push("This thread has no name yet — rename_thread with its thread_id makes it readable for everyone.");
445
526
  }
@@ -472,6 +553,22 @@ export async function callTool(client, name, rawArgs) {
472
553
  .join("\n");
473
554
  return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
474
555
  }
556
+ case "save_attachment": {
557
+ const input = schemas.save_attachment.parse(args);
558
+ const attachment = await client.downloadAttachment(input.attachment_id);
559
+ const target = await attachmentTarget(input.path, input.attachment_id, attachment.filename);
560
+ await mkdir(path.dirname(target), { recursive: true });
561
+ try {
562
+ await writeFile(target, attachment.data, { flag: input.overwrite ? "w" : "wx" });
563
+ }
564
+ catch (error) {
565
+ if (error.code === "EEXIST") {
566
+ throw new Error(`a file already exists at ${target} — pass overwrite: true or choose another path`);
567
+ }
568
+ throw error;
569
+ }
570
+ return `Saved ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.data.length)}) to ${target}`;
571
+ }
475
572
  case "mark_messages": {
476
573
  const input = schemas.mark_messages.parse(args);
477
574
  if (input.read === undefined && input.starred === undefined) {
@@ -567,6 +664,42 @@ export async function callTool(client, name, rawArgs) {
567
664
  throw new Error(`Unknown tool: ${name}`);
568
665
  }
569
666
  }
667
+ /** Lit sur le disque local les fichiers à joindre, en refusant d'emblée ceux qui dépassent les limites. */
668
+ async function readAttachments(entries) {
669
+ let total = 0;
670
+ const attachments = [];
671
+ for (const entry of entries) {
672
+ const file = path.resolve(entry.path);
673
+ const info = await stat(file).catch(() => null);
674
+ if (!info?.isFile())
675
+ throw new Error(`attachment is not a readable file: ${file}`);
676
+ if (info.size > MAX_ATTACHMENT_BYTES) {
677
+ throw new Error(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${file} (${formatBytes(info.size)})`);
678
+ }
679
+ total += info.size;
680
+ if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
681
+ throw new Error(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`);
682
+ }
683
+ const filename = entry.name ?? path.basename(file);
684
+ attachments.push({
685
+ filename,
686
+ mime_type: MIME_TYPES[path.extname(filename).toLowerCase()] ?? null,
687
+ content_base64: (await readFile(file)).toString("base64"),
688
+ });
689
+ }
690
+ return attachments;
691
+ }
692
+ /**
693
+ * Chemin d'écriture d'une pièce jointe : dans le répertoire donné sous son propre nom, au
694
+ * chemin donné s'il désigne un fichier, ou dans un répertoire temporaire propre à la pièce.
695
+ */
696
+ async function attachmentTarget(requested, attachmentId, filename) {
697
+ if (!requested)
698
+ return path.join(DEFAULT_ATTACHMENT_DIR, attachmentId, filename);
699
+ const resolved = path.resolve(requested);
700
+ const isDirectory = /[\\/]$/.test(requested) || (await stat(resolved).catch(() => null))?.isDirectory();
701
+ return isDirectory ? path.join(resolved, filename) : resolved;
702
+ }
570
703
  async function renameMailbox(client, name, description) {
571
704
  const agent = await client.updateProfile({ display_name: name, description });
572
705
  return [
@@ -752,7 +885,7 @@ function formatSummary(message) {
752
885
  `${flags} ${message.subject}${priority}`,
753
886
  ` from ${message.from} to ${message.to || "—"} · ${message.created_at}`,
754
887
  ` ${message.preview}`,
755
- ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
888
+ ` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}${message.attachments.length > 0 ? ` · ${message.attachments.length} attachment(s)` : ""}`,
756
889
  ].join("\n");
757
890
  }
758
891
  function formatMessage(message) {
@@ -767,5 +900,11 @@ function formatMessage(message) {
767
900
  ]
768
901
  .filter(Boolean)
769
902
  .join("\n");
770
- return `${header}\n\n${message.body}`;
903
+ const attachments = message.attachments.length > 0
904
+ ? `\n\nAttachments (save with save_attachment):\n${message.attachments.map(formatAttachment).join("\n")}`
905
+ : "";
906
+ return `${header}\n\n${message.body}${attachments}`;
907
+ }
908
+ function formatAttachment(attachment) {
909
+ return ` ${attachment.attachment_id} · ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.size)})`;
771
910
  }
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { timingSafeEqual } from "node:crypto";
6
6
  import { rateLimit } from "express-rate-limit";
7
7
  import { z } from "zod";
8
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, contentDisposition } from "../core/attachments.js";
8
9
  import { MessengerError, normalizeAgentId } from "../core/store.js";
9
10
  import { PRIORITIES } from "../core/types.js";
10
11
  import { WatcherRegistry } from "./watchers.js";
@@ -12,6 +13,9 @@ import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-ses
12
13
  import { APP_VERSION } from "../version.js";
13
14
  import { logger } from "../logger.js";
14
15
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
16
+ const JSON_TYPES = ["application/json", "application/*+json"];
17
+ /** Corps d'un envoi : le texte, plus les pièces jointes encodées en base64 (4 octets pour 3). */
18
+ const SEND_BODY_LIMIT = Math.ceil((MAX_ATTACHMENTS_TOTAL_BYTES * 4) / 3) + 1024 * 1024;
15
19
  const agentIdSchema = z.string().trim().min(1).max(128);
16
20
  const idListSchema = z.array(z.string().trim().min(1)).min(1).max(200);
17
21
  /** Nom et description de la boîte : ce que l'agent pose lui-même sur sa session. */
@@ -34,6 +38,14 @@ const sendSchema = z.object({
34
38
  reply_to: z.string().trim().min(1).nullish(),
35
39
  thread_title: z.string().trim().min(1).max(200).nullish(),
36
40
  thread_description: z.string().trim().min(1).max(2000).nullish(),
41
+ attachments: z
42
+ .array(z.object({
43
+ filename: z.string().trim().min(1).max(255),
44
+ mime_type: z.string().trim().max(255).nullish(),
45
+ content_base64: z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, "expected base64 content"),
46
+ }))
47
+ .max(MAX_ATTACHMENTS)
48
+ .optional(),
37
49
  });
38
50
  const threadLabelSchema = z
39
51
  .object({
@@ -151,7 +163,10 @@ export function createApp({ store, config }) {
151
163
  },
152
164
  crossOriginEmbedderPolicy: false,
153
165
  }));
154
- app.use(express.json({ type: ["application/json", "application/*+json"], limit: "2mb" }));
166
+ // L'envoi d'un message a sa propre limite, plus large pour ses pièces jointes, et ne
167
+ // s'applique qu'une fois l'agent authentifié.
168
+ const jsonBody = express.json({ type: JSON_TYPES, limit: "2mb" });
169
+ app.use((req, res, next) => (isSendRequest(req) ? next() : jsonBody(req, res, next)));
155
170
  app.use(rateLimit({
156
171
  windowMs: config.rateLimit.windowMs,
157
172
  limit: config.rateLimit.max,
@@ -209,6 +224,9 @@ export function createApp({ store, config }) {
209
224
  admin.get("/messages/:id", (req, res) => {
210
225
  res.json(store.getMessageAdmin(String(req.params.id)));
211
226
  });
227
+ admin.get("/attachments/:id", (req, res) => {
228
+ sendAttachment(res, store.getAttachmentAdmin(String(req.params.id)));
229
+ });
212
230
  // Déclarée avant `/threads/:id` : la console lit d'abord les fils, le détail ensuite.
213
231
  admin.get("/threads", (req, res) => {
214
232
  const query = adminThreadsSchema.parse(req.query);
@@ -373,7 +391,7 @@ export function createApp({ store, config }) {
373
391
  api.get("/mailbox", (req, res) => {
374
392
  res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
375
393
  });
376
- api.post("/messages", (req, res) => {
394
+ api.post("/messages", express.json({ type: JSON_TYPES, limit: SEND_BODY_LIMIT }), (req, res) => {
377
395
  const input = sendSchema.parse(req.body ?? {});
378
396
  const result = store.sendMessage({
379
397
  from: req.agentId,
@@ -384,6 +402,11 @@ export function createApp({ store, config }) {
384
402
  reply_to: input.reply_to ?? null,
385
403
  thread_title: input.thread_title ?? null,
386
404
  thread_description: input.thread_description ?? null,
405
+ attachments: (input.attachments ?? []).map((a) => ({
406
+ filename: a.filename,
407
+ mime_type: a.mime_type ?? null,
408
+ data: Buffer.from(a.content_base64, "base64"),
409
+ })),
387
410
  });
388
411
  logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to}`);
389
412
  res.status(201).json(result);
@@ -409,6 +432,9 @@ export function createApp({ store, config }) {
409
432
  const markRead = req.query.mark_read !== "false";
410
433
  res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
411
434
  });
435
+ api.get("/attachments/:id", (req, res) => {
436
+ sendAttachment(res, store.getAttachment(req.agentId, String(req.params.id)));
437
+ });
412
438
  api.get("/threads", (req, res) => {
413
439
  const query = listThreadsQuerySchema.parse(req.query);
414
440
  res.json({ threads: store.listThreads(req.agentId, query) });
@@ -485,12 +511,32 @@ export function createApp({ store, config }) {
485
511
  res.status(400).json({ error: "invalid request", details: z.treeifyError(err) });
486
512
  return;
487
513
  }
514
+ // Erreurs du lecteur de corps (trop gros, JSON illisible) : leur message est fait pour être lu.
515
+ if (isExposedHttpError(err)) {
516
+ res.status(err.status).json({ error: err.message });
517
+ return;
518
+ }
488
519
  logger.error("unhandled error", err);
489
520
  // Le détail reste dans les logs : la réponse ne doit rien révéler de l'hôte.
490
521
  res.status(500).json({ error: "internal server error" });
491
522
  });
492
523
  return app;
493
524
  }
525
+ function isExposedHttpError(err) {
526
+ if (typeof err !== "object" || err === null)
527
+ return false;
528
+ const { expose, status } = err;
529
+ return expose === true && typeof status === "number" && status >= 400 && status < 500;
530
+ }
531
+ function isSendRequest(req) {
532
+ return req.method === "POST" && req.path === "/api/messages";
533
+ }
534
+ function sendAttachment(res, attachment) {
535
+ res.setHeader("content-type", attachment.mime_type);
536
+ res.setHeader("content-length", String(attachment.data.length));
537
+ res.setHeader("content-disposition", contentDisposition(attachment.filename));
538
+ res.end(attachment.data);
539
+ }
494
540
  /**
495
541
  * Exige un jeton connu du serveur sur toute requête entrante.
496
542
  *
@@ -27,6 +27,7 @@ const ICONS = {
27
27
  link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
28
28
  chevron: ["m9 18 6-6-6-6"],
29
29
  info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
30
+ paperclip: ["m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"],
30
31
  trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
31
32
  };
32
33
 
@@ -961,6 +962,9 @@ function fillMessage(item, message, isLast) {
961
962
  column.appendChild(content);
962
963
 
963
964
  content.appendChild(messageText(message));
965
+ if (message.attachments && message.attachments.length > 0) {
966
+ content.appendChild(attachmentList(message.attachments));
967
+ }
964
968
 
965
969
  const foot = node("div", "message-foot");
966
970
  foot.appendChild(
@@ -986,6 +990,30 @@ function messageText(message) {
986
990
  return container;
987
991
  }
988
992
 
993
+ /** Pièces jointes d'un message : un lien de téléchargement par fichier. */
994
+ function attachmentList(attachments) {
995
+ const list = node("ul", "attachments");
996
+ for (const attachment of attachments) {
997
+ const item = node("li");
998
+ const link = node("a", "attachment");
999
+ link.href = `/admin/attachments/${encodeURIComponent(attachment.attachment_id)}`;
1000
+ link.download = attachment.filename;
1001
+ link.title = attachment.mime_type;
1002
+ link.appendChild(icon("paperclip"));
1003
+ link.appendChild(node("span", "attachment-name", attachment.filename));
1004
+ link.appendChild(node("span", "attachment-size", formatSize(attachment.size)));
1005
+ item.appendChild(link);
1006
+ list.appendChild(item);
1007
+ }
1008
+ return list;
1009
+ }
1010
+
1011
+ function formatSize(bytes) {
1012
+ if (bytes < 1024) return `${bytes} o`;
1013
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
1014
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
1015
+ }
1016
+
989
1017
  // --- Détails d'un message --------------------------------------------------
990
1018
 
991
1019
  /**
@@ -1627,6 +1627,50 @@ body {
1627
1627
  font-weight: 600;
1628
1628
  }
1629
1629
 
1630
+ .attachments {
1631
+ display: flex;
1632
+ flex-wrap: wrap;
1633
+ gap: 8px;
1634
+ margin: 10px 0 0;
1635
+ padding: 0;
1636
+ list-style: none;
1637
+ }
1638
+
1639
+ .attachment {
1640
+ display: inline-flex;
1641
+ align-items: center;
1642
+ gap: 6px;
1643
+ max-width: 100%;
1644
+ padding: 4px 10px;
1645
+ border: 1px solid var(--border);
1646
+ border-radius: var(--radius);
1647
+ background: var(--panel);
1648
+ color: var(--text);
1649
+ font-size: 12px;
1650
+ text-decoration: none;
1651
+ }
1652
+
1653
+ .attachment:hover {
1654
+ background: var(--accent-soft);
1655
+ border-color: var(--accent-softer);
1656
+ }
1657
+
1658
+ .attachment .icon {
1659
+ flex: none;
1660
+ color: var(--muted);
1661
+ }
1662
+
1663
+ .attachment-name {
1664
+ overflow: hidden;
1665
+ text-overflow: ellipsis;
1666
+ white-space: nowrap;
1667
+ }
1668
+
1669
+ .attachment-size {
1670
+ flex: none;
1671
+ color: var(--muted);
1672
+ }
1673
+
1630
1674
  .message-foot {
1631
1675
  display: flex;
1632
1676
  align-items: center;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.29";
1
+ export declare const APP_VERSION = "1.0.30";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.29";
2
+ export const APP_VERSION = "1.0.30";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.29",
3
+ "version": "1.0.30",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",