voicemode-channel 0.2.0 → 0.3.1

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/dist/gateway.js CHANGED
@@ -268,7 +268,7 @@ export class GatewayClient extends EventEmitter {
268
268
  type: 'ready',
269
269
  device: {
270
270
  platform: 'channel-server',
271
- appVersion: '0.1.4',
271
+ appVersion: '0.2.1',
272
272
  name: `channel@${hostname()}`,
273
273
  },
274
274
  };
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { join } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { randomBytes } from 'node:crypto';
22
22
  import { GatewayClient, get_project_context } from './gateway.js';
23
+ import { write_message, list_messages, read_message, mark_read, count_unread } from './maildir.js';
23
24
  import { login } from './auth.js';
24
25
  import { load_credentials, is_expired, CREDENTIALS_FILE, get_valid_token } from './credentials.js';
25
26
  // ---------------------------------------------------------------------------
@@ -121,7 +122,16 @@ if (process.env.VOICEMODE_CHANNEL_ENABLED !== 'true') {
121
122
  }
122
123
  const WS_URL = process.env.VOICEMODE_CONNECT_WS_URL ?? 'wss://voicemode.dev/ws';
123
124
  const CHANNEL_NAME = 'voicemode-channel';
124
- const CHANNEL_VERSION = '0.1.4';
125
+ // Read version from package.json at runtime to avoid hardcoded version drift
126
+ const CHANNEL_VERSION = (() => {
127
+ try {
128
+ const pkg_path = new URL('../package.json', import.meta.url);
129
+ return JSON.parse(readFileSync(pkg_path, 'utf8')).version ?? '0.0.0';
130
+ }
131
+ catch {
132
+ return '0.0.0';
133
+ }
134
+ })();
125
135
  const INSTRUCTIONS = [
126
136
  'Events from VoiceMode appear as <channel source="voicemode-channel" caller="NAME">TRANSCRIPT</channel>.',
127
137
  'These are inbound voice messages from a user speaking on their phone or web app.',
@@ -141,6 +151,9 @@ let currentProfile = {
141
151
  voice: null,
142
152
  presence: 'available',
143
153
  };
154
+ // Track the last inbound caller name so outbound replies can reference it.
155
+ // Falls back to 'user' if no inbound message has been received yet.
156
+ let last_caller_name = 'user';
144
157
  // ---------------------------------------------------------------------------
145
158
  // MCP server with channel capability
146
159
  // ---------------------------------------------------------------------------
@@ -167,7 +180,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
167
180
  name: 'reply',
168
181
  description: 'Send a voice reply to the user through VoiceMode. ' +
169
182
  'Use this to respond to inbound voice channel events. ' +
170
- 'The reply is spoken aloud on the user\'s device via TTS.',
183
+ 'The reply is spoken aloud on the user\'s device via TTS. ' +
184
+ 'Pass in_reply_to to mark the source message with the R (Replied) flag.',
171
185
  inputSchema: {
172
186
  type: 'object',
173
187
  properties: {
@@ -183,6 +197,10 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
183
197
  type: 'boolean',
184
198
  description: 'Whether to listen for user response after speaking (default: false)',
185
199
  },
200
+ in_reply_to: {
201
+ type: 'string',
202
+ description: 'Filename of the inbound message being replied to; receives R flag on successful send',
203
+ },
186
204
  },
187
205
  required: ['text'],
188
206
  },
@@ -228,6 +246,89 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
228
246
  },
229
247
  },
230
248
  },
249
+ {
250
+ name: 'list_messages',
251
+ description: 'List voice messages from the Maildir conversation history. ' +
252
+ 'By default, only shows messages from the current agent session. ' +
253
+ 'Use all_sessions to see messages from all sessions. ' +
254
+ 'Pass include_body: true to fetch bodies in bulk (truncated by body_max_length). ' +
255
+ 'Pass unread: true to see only unread messages (no S flag).',
256
+ inputSchema: {
257
+ type: 'object',
258
+ properties: {
259
+ all_sessions: {
260
+ type: 'boolean',
261
+ description: 'If true, show messages from all sessions (default: false, current session only)',
262
+ },
263
+ direction: {
264
+ type: 'string',
265
+ description: 'Filter by message direction',
266
+ enum: ['inbound', 'outbound'],
267
+ },
268
+ limit: {
269
+ type: 'number',
270
+ description: 'Maximum number of messages to return (default: 50)',
271
+ },
272
+ include_body: {
273
+ type: 'boolean',
274
+ description: 'If true, include message bodies in the response (default: false)',
275
+ },
276
+ body_max_length: {
277
+ type: 'number',
278
+ description: 'Max body length when include_body is true; 0 = unlimited (default: 2000)',
279
+ },
280
+ unread: {
281
+ type: 'boolean',
282
+ description: 'If true, only return unread messages; if false, only read; omit for both',
283
+ },
284
+ },
285
+ },
286
+ },
287
+ {
288
+ name: 'read_message',
289
+ description: 'Read the full content of a voice message by filename. ' +
290
+ 'Use list_messages first to find message filenames. ' +
291
+ 'By default, marks the message as Seen (moves from new/ to cur/ with S flag). ' +
292
+ 'Pass mark_read: false to leave the message unread.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ filename: {
297
+ type: 'string',
298
+ description: 'The message filename (e.g. "vm-abc123def456")',
299
+ },
300
+ mark_read: {
301
+ type: 'boolean',
302
+ description: 'Whether to mark the message as Seen on read (default: true)',
303
+ },
304
+ },
305
+ required: ['filename'],
306
+ },
307
+ },
308
+ {
309
+ name: 'mark_read',
310
+ description: 'Apply Maildir flags to one or more messages (bulk supported). ' +
311
+ 'Default flag is "S" (Seen). Files are moved from new/ to cur/ with a ' +
312
+ ':2,FLAGS suffix per the Maildir spec. Flags are merged with any existing ' +
313
+ 'flags on the file (e.g. marking an "R" message as Seen yields ":2,RS"). ' +
314
+ 'Use this when you want to mark multiple messages read in one call, or to ' +
315
+ 'apply non-Seen flags (R=Replied, F=Flagged, T=Trashed, D=Draft).',
316
+ inputSchema: {
317
+ type: 'object',
318
+ properties: {
319
+ filenames: {
320
+ type: 'array',
321
+ items: { type: 'string' },
322
+ description: 'Message filenames to mark (from list_messages)',
323
+ },
324
+ flags: {
325
+ type: 'string',
326
+ description: 'Flag letters to apply, e.g. "S", "RS" (default: "S")',
327
+ },
328
+ },
329
+ required: ['filenames'],
330
+ },
331
+ },
231
332
  ],
232
333
  };
233
334
  });
@@ -242,6 +343,15 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
242
343
  if (name === 'reply') {
243
344
  return handle_reply_tool(args);
244
345
  }
346
+ if (name === 'list_messages') {
347
+ return handle_list_messages_tool(args);
348
+ }
349
+ if (name === 'read_message') {
350
+ return handle_read_message_tool(args);
351
+ }
352
+ if (name === 'mark_read') {
353
+ return handle_mark_read_tool(args);
354
+ }
245
355
  return {
246
356
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
247
357
  isError: true,
@@ -280,6 +390,16 @@ function handle_status_tool() {
280
390
  lines.push('Auth: not authenticated');
281
391
  }
282
392
  lines.push('');
393
+ // Unread message count (supports notification-append pattern)
394
+ try {
395
+ const unread = count_unread();
396
+ lines.push(`Unread: ${unread}`);
397
+ }
398
+ catch (err) {
399
+ const message = err instanceof Error ? err.message : String(err);
400
+ lines.push(`Unread: unavailable (${message})`);
401
+ }
402
+ lines.push('');
283
403
  // Profile
284
404
  lines.push(`Profile: set`);
285
405
  lines.push(` Name: ${currentProfile.name}`);
@@ -305,6 +425,9 @@ function handle_reply_tool(args) {
305
425
  }
306
426
  const voice = typeof args?.voice === 'string' ? args.voice : (currentProfile.voice || undefined);
307
427
  const wait_for_response = typeof args?.wait_for_response === 'boolean' ? args.wait_for_response : undefined;
428
+ const in_reply_to = typeof args?.in_reply_to === 'string' && args.in_reply_to.trim().length > 0
429
+ ? args.in_reply_to.trim()
430
+ : undefined;
308
431
  // Check gateway connection
309
432
  if (!gateway || gateway.state !== 'connected') {
310
433
  return {
@@ -332,6 +455,40 @@ function handle_reply_tool(args) {
332
455
  };
333
456
  }
334
457
  log(`Sent reply via gateway: id=${msg_id} text="${truncate(text.trim(), 80)}"`);
458
+ // Apply R (Replied) flag to the source message when reply-context is provided.
459
+ // Best-effort -- never break reply flow if the source file is gone.
460
+ if (in_reply_to) {
461
+ try {
462
+ const results = mark_read([in_reply_to], 'R');
463
+ const r = results[0];
464
+ if (r?.found) {
465
+ log(`Marked ${in_reply_to} with R flag -> ${r.new_filename}`, 'DEBUG');
466
+ }
467
+ else {
468
+ log(`R flag: source message not found: ${in_reply_to}`, 'WARN');
469
+ }
470
+ }
471
+ catch (err) {
472
+ const message = err instanceof Error ? err.message : String(err);
473
+ log(`R flag application failed (non-fatal): ${message}`, 'WARN');
474
+ }
475
+ }
476
+ // Persist outbound message to Maildir (best-effort -- never break reply flow)
477
+ try {
478
+ write_message({
479
+ direction: 'outbound',
480
+ from_name: currentProfile.name,
481
+ to_name: last_caller_name,
482
+ text: text.trim(),
483
+ session_id: gateway.session_id ?? 'unknown',
484
+ agent_session_id: gateway.agent_session_id ?? 'unknown',
485
+ agent_name: currentProfile.name,
486
+ });
487
+ }
488
+ catch (err) {
489
+ const message = err instanceof Error ? err.message : String(err);
490
+ log(`Maildir write failed (non-fatal): ${message}`, 'WARN');
491
+ }
335
492
  return {
336
493
  content: [{
337
494
  type: 'text',
@@ -379,6 +536,118 @@ function handle_profile_tool(args) {
379
536
  };
380
537
  }
381
538
  // ---------------------------------------------------------------------------
539
+ // Tool handler: list_messages
540
+ // ---------------------------------------------------------------------------
541
+ function handle_list_messages_tool(args) {
542
+ const all_sessions = args?.all_sessions === true;
543
+ const direction = args?.direction;
544
+ const limit = typeof args?.limit === 'number' ? Math.max(1, Math.min(args.limit, 500)) : 50;
545
+ const include_body = args?.include_body === true;
546
+ const body_max_length = typeof args?.body_max_length === 'number' ? Math.max(0, args.body_max_length) : 2000;
547
+ const unread = typeof args?.unread === 'boolean' ? args.unread : undefined;
548
+ // Default to current session unless all_sessions is true
549
+ const agent_session_id = all_sessions ? undefined : (gateway?.agent_session_id ?? undefined);
550
+ // Validate direction if provided
551
+ if (direction !== undefined && direction !== 'inbound' && direction !== 'outbound') {
552
+ return {
553
+ content: [{ type: 'text', text: 'Error: direction must be "inbound" or "outbound"' }],
554
+ isError: true,
555
+ };
556
+ }
557
+ const messages = list_messages({ agent_session_id, direction, limit, include_body, body_max_length, unread });
558
+ if (messages.length === 0) {
559
+ const scope = all_sessions ? 'any session' : 'the current session';
560
+ const state = unread === true ? 'unread ' : unread === false ? 'read ' : '';
561
+ return {
562
+ content: [{ type: 'text', text: `No ${state}messages found for ${scope}.` }],
563
+ };
564
+ }
565
+ // When bodies are requested, return structured JSON so the caller can work with them.
566
+ if (include_body) {
567
+ return {
568
+ content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }],
569
+ };
570
+ }
571
+ // Format as a summary list (filename, direction, date, subject preview)
572
+ const lines = messages.map((msg) => {
573
+ const dir_arrow = msg.direction === 'inbound' ? '<-' : '->';
574
+ const preview = msg.subject.length > 60 ? msg.subject.slice(0, 60) + '...' : msg.subject;
575
+ return `${msg.filename} ${dir_arrow} ${msg.date} ${preview}`;
576
+ });
577
+ const header = `Messages (${messages.length}${messages.length >= limit ? '+' : ''}):`;
578
+ return {
579
+ content: [{ type: 'text', text: [header, ...lines].join('\n') }],
580
+ };
581
+ }
582
+ // ---------------------------------------------------------------------------
583
+ // Tool handler: read_message
584
+ // ---------------------------------------------------------------------------
585
+ function handle_read_message_tool(args) {
586
+ const filename = args?.filename;
587
+ if (typeof filename !== 'string' || filename.trim().length === 0) {
588
+ return {
589
+ content: [{ type: 'text', text: 'Error: filename parameter is required and must be non-empty' }],
590
+ isError: true,
591
+ };
592
+ }
593
+ // mark_read defaults to true -- callers pass false to opt out
594
+ const should_mark = typeof args?.mark_read === 'boolean' ? args.mark_read : true;
595
+ const message = read_message(filename.trim(), { mark_read: should_mark });
596
+ if (!message) {
597
+ return {
598
+ content: [{ type: 'text', text: `Message not found: ${filename}` }],
599
+ isError: true,
600
+ };
601
+ }
602
+ return {
603
+ content: [{ type: 'text', text: JSON.stringify(message, null, 2) }],
604
+ };
605
+ }
606
+ // ---------------------------------------------------------------------------
607
+ // Tool handler: mark_read
608
+ // ---------------------------------------------------------------------------
609
+ function handle_mark_read_tool(args) {
610
+ const filenames_arg = args?.filenames;
611
+ if (!Array.isArray(filenames_arg) || filenames_arg.length === 0) {
612
+ return {
613
+ content: [{ type: 'text', text: 'Error: filenames parameter is required and must be a non-empty array' }],
614
+ isError: true,
615
+ };
616
+ }
617
+ // Validate each filename is a non-empty string
618
+ const filenames = [];
619
+ for (const item of filenames_arg) {
620
+ if (typeof item !== 'string' || item.trim().length === 0) {
621
+ return {
622
+ content: [{ type: 'text', text: 'Error: every filename must be a non-empty string' }],
623
+ isError: true,
624
+ };
625
+ }
626
+ filenames.push(item.trim());
627
+ }
628
+ const flags = typeof args?.flags === 'string' && args.flags.length > 0 ? args.flags : 'S';
629
+ const results = mark_read(filenames, flags);
630
+ // Format a human-readable summary alongside the structured result
631
+ const found_count = results.filter(r => r.found).length;
632
+ const missing_count = results.length - found_count;
633
+ const lines = [];
634
+ lines.push(`mark_read: ${found_count}/${results.length} marked with flags=${flags}`);
635
+ if (missing_count > 0) {
636
+ lines.push(` ${missing_count} not found`);
637
+ }
638
+ for (const r of results) {
639
+ if (r.found) {
640
+ lines.push(` [OK] ${r.filename} -> ${r.new_filename}`);
641
+ }
642
+ else {
643
+ lines.push(` [??] ${r.filename} (not found)`);
644
+ }
645
+ }
646
+ return {
647
+ content: [{ type: 'text', text: lines.join('\n') }],
648
+ };
649
+ }
650
+ // ---------------------------------------------------------------------------
382
651
  // Push a channel notification for an inbound voice event
383
652
  // ---------------------------------------------------------------------------
384
653
  const EXTERNAL_MESSAGE_PREFIX = '[VoiceMode Connect - External Message]: ';
@@ -472,11 +741,32 @@ function start_gateway() {
472
741
  const device_id = typeof user_id === 'string' && user_id.length > 0
473
742
  ? user_id
474
743
  : undefined;
744
+ // Remember caller name for outbound reply attribution
745
+ last_caller_name = caller;
475
746
  log(`Received voice event: from="${caller}" text="${truncate(safe_text.trim(), 80)}"`);
476
747
  push_voice_event(caller, safe_text.trim(), device_id).catch((err) => {
477
748
  const message = err instanceof Error ? err.message : String(err);
478
749
  log(`Error pushing voice event to channel: ${message}`);
479
750
  });
751
+ // Persist inbound message to Maildir (best-effort -- never break voice pipeline)
752
+ try {
753
+ const filename = write_message({
754
+ direction: 'inbound',
755
+ from_name: caller,
756
+ to_name: currentProfile.name,
757
+ text: safe_text.trim(),
758
+ session_id: gateway?.session_id ?? 'unknown',
759
+ agent_session_id: gateway?.agent_session_id ?? 'unknown',
760
+ agent_name: currentProfile.name,
761
+ });
762
+ if (filename) {
763
+ log(`Maildir: wrote inbound message ${filename}`, 'DEBUG');
764
+ }
765
+ }
766
+ catch (err) {
767
+ const message = err instanceof Error ? err.message : String(err);
768
+ log(`Maildir write failed (non-fatal): ${message}`, 'WARN');
769
+ }
480
770
  });
481
771
  // Start the connection (non-blocking -- reconnects in the background)
482
772
  gateway.start().catch((err) => {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Maildir persistence for VoiceMode voice messages.
3
+ *
4
+ * Writes inbound (user speaks) and outbound (agent replies) messages to Maildir
5
+ * format so they can be indexed by notmuch and accessed via MCP tools.
6
+ *
7
+ * Maildir protocol: write to tmp/ first, then rename() to new/ (atomic, no locking).
8
+ * Dedup: content-hash filename (sha256(from|timestamp|text)[:16]) prevents duplicates.
9
+ *
10
+ * Environment variables:
11
+ * VOICEMODE_MAILDIR_PATH Override default ~/.voicemode/maildir/channel
12
+ * VOICEMODE_MAILDIR_ENABLED Set to 'false' to disable persistence
13
+ */
14
+ export interface VoiceMessage {
15
+ direction: 'inbound' | 'outbound';
16
+ from_name: string;
17
+ to_name: string;
18
+ text: string;
19
+ session_id: string;
20
+ agent_session_id: string;
21
+ agent_name: string;
22
+ timestamp?: Date;
23
+ }
24
+ export interface MaildirMessage {
25
+ filename: string;
26
+ from: string;
27
+ to: string;
28
+ date: string;
29
+ subject: string;
30
+ direction: string;
31
+ session_id: string;
32
+ agent_session_id: string;
33
+ agent_name: string;
34
+ body: string;
35
+ }
36
+ export declare function get_maildir_path(): string;
37
+ /**
38
+ * Write a voice message to Maildir.
39
+ *
40
+ * Returns the filename if written (or already exists), null if persistence is disabled.
41
+ * Uses atomic tmp/ -> new/ rename to guarantee no partial reads.
42
+ * Silently skips duplicate messages (same from/timestamp/text hash).
43
+ */
44
+ export declare function write_message(msg: VoiceMessage): string | null;
45
+ /**
46
+ * Read a single message by filename from the Maildir.
47
+ *
48
+ * Security: rejects filenames containing '..' or '/' and verifies the
49
+ * resolved path is within the Maildir directory. Only returns messages
50
+ * with X-Transport: voicemode-connect header.
51
+ *
52
+ * Base-name lookup: the caller can pass either the bare filename or an
53
+ * already-flagged `:2,FLAGS` form -- we look up by base name across new/
54
+ * and cur/, matching the behaviour of mark_read.
55
+ *
56
+ * By default, a successful read marks the message as Seen (moves to cur/
57
+ * with S flag). Pass `{ mark_read: false }` to opt out. The returned
58
+ * MaildirMessage's `filename` field reflects the on-disk name after any
59
+ * rename (so callers can use it for subsequent operations).
60
+ *
61
+ * Returns null if the file doesn't exist, fails security checks, or
62
+ * has the wrong X-Transport header.
63
+ */
64
+ export declare function read_message(filename: string, options?: {
65
+ mark_read?: boolean;
66
+ }): MaildirMessage | null;
67
+ /** Marker appended to bodies that exceed body_max_length. */
68
+ export declare const TRUNCATION_MARKER = "... [truncated]";
69
+ /**
70
+ * Truncate a body string to at most `max_length` characters, appending a
71
+ * clear marker when truncation happens. A `max_length` of 0 means unlimited
72
+ * (returns the body unchanged). Negative values are treated as 0.
73
+ */
74
+ export declare function truncate_body(body: string, max_length: number): string;
75
+ /**
76
+ * List messages from the Maildir, filtered by session, direction, and read state.
77
+ *
78
+ * Scans both new/ and cur/ directories. Only includes messages with
79
+ * X-Transport: voicemode-connect header.
80
+ *
81
+ * Options:
82
+ * - include_body: when false (default), returned messages have body=''
83
+ * to keep responses compact. Set true to return bodies (truncated by
84
+ * body_max_length).
85
+ * - body_max_length: maximum body length when include_body is true.
86
+ * Default 2000. Pass 0 for unlimited. Bodies past the limit get a
87
+ * "... [truncated]" marker appended.
88
+ * - unread: undefined (default) returns both read and unread. true
89
+ * returns only unread messages (in new/, or in cur/ without S flag).
90
+ * false returns only read messages (in cur/ with S flag).
91
+ *
92
+ * Returns messages sorted by date descending (newest first).
93
+ */
94
+ export declare function list_messages(options: {
95
+ agent_session_id?: string;
96
+ direction?: 'inbound' | 'outbound';
97
+ limit?: number;
98
+ include_body?: boolean;
99
+ body_max_length?: number;
100
+ unread?: boolean;
101
+ }): MaildirMessage[];
102
+ /**
103
+ * Parse a Maildir filename into its base and flag components.
104
+ *
105
+ * Maildir spec: filenames in cur/ use the form `<base>:2,<flags>` where flags
106
+ * are ASCII letters sorted alphabetically (e.g. "RS" = Replied + Seen).
107
+ * Files in new/ typically have no suffix.
108
+ *
109
+ * Only the experimental `:2,` variant is recognized -- `:1,` filenames are
110
+ * treated as having no flags, since that form is not used by notmuch/neomutt.
111
+ */
112
+ export declare function parse_maildir_filename(filename: string): {
113
+ base: string;
114
+ flags: string;
115
+ };
116
+ /**
117
+ * Merge two flag strings into a single set of unique, alphabetically sorted
118
+ * uppercase ASCII-letter flags.
119
+ *
120
+ * Non-letter characters are dropped. Case is normalized to uppercase.
121
+ * Duplicates are removed. Output is sorted (standard Maildir flag order).
122
+ */
123
+ export declare function merge_flags(existing: string, new_flags: string): string;
124
+ /**
125
+ * Build a full Maildir filename from its base and flag components.
126
+ * When flags is empty, returns just the base (suitable for new/ files).
127
+ */
128
+ export declare function build_maildir_filename(base: string, flags: string): string;
129
+ /**
130
+ * Count unread messages in the Maildir.
131
+ *
132
+ * Unread = files in new/ (never touched) + files in cur/ without the S flag.
133
+ * Filters by filename prefix `vm-` so we don't have to read file contents to
134
+ * decide whether a file is a voicemode-channel message -- keeps the counter
135
+ * cheap enough to call on every status check / notification append.
136
+ */
137
+ export declare function count_unread(): number;
138
+ export interface MarkReadResult {
139
+ /** The filename as passed in by the caller. */
140
+ filename: string;
141
+ /** New filename (with flags applied) if the file was found and renamed. */
142
+ new_filename: string | null;
143
+ /** True if the file was located in new/ or cur/. */
144
+ found: boolean;
145
+ }
146
+ /**
147
+ * Mark one or more messages with Maildir flags, moving them from new/ to cur/.
148
+ *
149
+ * For each filename:
150
+ * 1. Locate the file by its base name (with or without a :2,FLAGS suffix)
151
+ * in either new/ or cur/.
152
+ * 2. Merge the requested flags with any existing flags (unique, sorted).
153
+ * 3. Rename the file to `<base>:2,<flags>` in cur/.
154
+ *
155
+ * Security: filenames containing '..' or '/' are rejected (returns found=false).
156
+ *
157
+ * Idempotent: marking an already-marked file with the same flags is a no-op
158
+ * rename (the file is already in cur/ with those flags).
159
+ *
160
+ * @param filenames Basenames of Maildir files (as returned by list_messages)
161
+ * @param flags Letters to apply (default "S" = Seen). Case-insensitive.
162
+ * @returns One result per input filename, in the same order.
163
+ */
164
+ export declare function mark_read(filenames: string[], flags?: string): MarkReadResult[];