mcp-twake-mail 1.0.0 → 2.0.0

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.
@@ -0,0 +1,875 @@
1
+ /**
2
+ * Tests for batch email operation MCP tools - batch_mark_read, batch_mark_unread, batch_move,
3
+ * batch_delete, batch_add_label, batch_remove_label.
4
+ */
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { registerBatchOperationTools } from './batch-operations.js';
7
+ describe('registerBatchOperationTools', () => {
8
+ let mockServer;
9
+ let mockJmapClient;
10
+ let mockLogger;
11
+ let registeredTools;
12
+ beforeEach(() => {
13
+ registeredTools = new Map();
14
+ // Mock MCP server
15
+ mockServer = {
16
+ registerTool: vi.fn((name, _options, handler) => {
17
+ registeredTools.set(name, handler);
18
+ }),
19
+ };
20
+ // Mock JMAP client
21
+ mockJmapClient = {
22
+ getSession: vi.fn(() => ({ accountId: 'account-1', apiUrl: 'https://jmap.example.com/api' })),
23
+ request: vi.fn(),
24
+ parseMethodResponse: vi.fn(),
25
+ };
26
+ // Mock logger (silent)
27
+ mockLogger = {
28
+ debug: vi.fn(),
29
+ info: vi.fn(),
30
+ warn: vi.fn(),
31
+ error: vi.fn(),
32
+ };
33
+ // Register all tools
34
+ registerBatchOperationTools(mockServer, mockJmapClient, mockLogger);
35
+ });
36
+ describe('batch_mark_read', () => {
37
+ it('returns success when all emails are marked as read', async () => {
38
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
39
+ methodResponses: [
40
+ [
41
+ 'Email/set',
42
+ {
43
+ accountId: 'account-1',
44
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
45
+ },
46
+ 'batchMarkRead',
47
+ ],
48
+ ],
49
+ });
50
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
51
+ success: true,
52
+ data: {
53
+ accountId: 'account-1',
54
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
55
+ },
56
+ });
57
+ const handler = registeredTools.get('batch_mark_read');
58
+ const result = await handler({ emailIds: ['email-1', 'email-2', 'email-3'] });
59
+ expect(result.isError).toBeUndefined();
60
+ const parsed = JSON.parse(result.content[0].text);
61
+ expect(parsed).toEqual({
62
+ success: true,
63
+ total: 3,
64
+ succeeded: 3,
65
+ failed: 0,
66
+ results: {
67
+ succeeded: ['email-1', 'email-2', 'email-3'],
68
+ failed: [],
69
+ },
70
+ });
71
+ });
72
+ it('handles partial failure with per-email reporting', async () => {
73
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
74
+ methodResponses: [
75
+ [
76
+ 'Email/set',
77
+ {
78
+ accountId: 'account-1',
79
+ updated: { 'email-1': null, 'email-3': null },
80
+ notUpdated: { 'email-2': { type: 'notFound', description: 'Email not found' } },
81
+ },
82
+ 'batchMarkRead',
83
+ ],
84
+ ],
85
+ });
86
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
87
+ success: true,
88
+ data: {
89
+ accountId: 'account-1',
90
+ updated: { 'email-1': null, 'email-3': null },
91
+ notUpdated: { 'email-2': { type: 'notFound', description: 'Email not found' } },
92
+ },
93
+ });
94
+ const handler = registeredTools.get('batch_mark_read');
95
+ const result = await handler({ emailIds: ['email-1', 'email-2', 'email-3'] });
96
+ expect(result.isError).toBeUndefined();
97
+ const parsed = JSON.parse(result.content[0].text);
98
+ expect(parsed).toEqual({
99
+ success: false,
100
+ total: 3,
101
+ succeeded: 2,
102
+ failed: 1,
103
+ results: {
104
+ succeeded: ['email-1', 'email-3'],
105
+ failed: [{ emailId: 'email-2', error: 'notFound: Email not found' }],
106
+ },
107
+ });
108
+ });
109
+ it('handles total failure with all emails in notUpdated', async () => {
110
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
111
+ methodResponses: [
112
+ [
113
+ 'Email/set',
114
+ {
115
+ accountId: 'account-1',
116
+ notUpdated: {
117
+ 'email-1': { type: 'notFound' },
118
+ 'email-2': { type: 'notFound' },
119
+ },
120
+ },
121
+ 'batchMarkRead',
122
+ ],
123
+ ],
124
+ });
125
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
126
+ success: true,
127
+ data: {
128
+ accountId: 'account-1',
129
+ notUpdated: {
130
+ 'email-1': { type: 'notFound' },
131
+ 'email-2': { type: 'notFound' },
132
+ },
133
+ },
134
+ });
135
+ const handler = registeredTools.get('batch_mark_read');
136
+ const result = await handler({ emailIds: ['email-1', 'email-2'] });
137
+ expect(result.isError).toBeUndefined();
138
+ const parsed = JSON.parse(result.content[0].text);
139
+ expect(parsed.success).toBe(false);
140
+ expect(parsed.total).toBe(2);
141
+ expect(parsed.succeeded).toBe(0);
142
+ expect(parsed.failed).toBe(2);
143
+ });
144
+ it('returns error when JMAP method fails', async () => {
145
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
146
+ methodResponses: [
147
+ ['error', { type: 'serverFail', description: 'Server error' }, 'batchMarkRead'],
148
+ ],
149
+ });
150
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
151
+ success: false,
152
+ error: { type: 'serverFail', description: 'Server error' },
153
+ });
154
+ const handler = registeredTools.get('batch_mark_read');
155
+ const result = await handler({ emailIds: ['email-1'] });
156
+ expect(result.isError).toBe(true);
157
+ expect(result.content[0].text).toContain('Failed to batch mark emails as read');
158
+ });
159
+ });
160
+ describe('batch_mark_unread', () => {
161
+ it('returns success when all emails are marked as unread', async () => {
162
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
163
+ methodResponses: [
164
+ [
165
+ 'Email/set',
166
+ {
167
+ accountId: 'account-1',
168
+ updated: { 'email-1': null, 'email-2': null },
169
+ },
170
+ 'batchMarkUnread',
171
+ ],
172
+ ],
173
+ });
174
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
175
+ success: true,
176
+ data: {
177
+ accountId: 'account-1',
178
+ updated: { 'email-1': null, 'email-2': null },
179
+ },
180
+ });
181
+ const handler = registeredTools.get('batch_mark_unread');
182
+ const result = await handler({ emailIds: ['email-1', 'email-2'] });
183
+ expect(result.isError).toBeUndefined();
184
+ const parsed = JSON.parse(result.content[0].text);
185
+ expect(parsed).toEqual({
186
+ success: true,
187
+ total: 2,
188
+ succeeded: 2,
189
+ failed: 0,
190
+ results: {
191
+ succeeded: ['email-1', 'email-2'],
192
+ failed: [],
193
+ },
194
+ });
195
+ });
196
+ it('handles partial failure', async () => {
197
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
198
+ methodResponses: [
199
+ [
200
+ 'Email/set',
201
+ {
202
+ accountId: 'account-1',
203
+ updated: { 'email-1': null },
204
+ notUpdated: { 'email-2': { type: 'invalidProperties' } },
205
+ },
206
+ 'batchMarkUnread',
207
+ ],
208
+ ],
209
+ });
210
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
211
+ success: true,
212
+ data: {
213
+ accountId: 'account-1',
214
+ updated: { 'email-1': null },
215
+ notUpdated: { 'email-2': { type: 'invalidProperties' } },
216
+ },
217
+ });
218
+ const handler = registeredTools.get('batch_mark_unread');
219
+ const result = await handler({ emailIds: ['email-1', 'email-2'] });
220
+ expect(result.isError).toBeUndefined();
221
+ const parsed = JSON.parse(result.content[0].text);
222
+ expect(parsed.success).toBe(false);
223
+ expect(parsed.succeeded).toBe(1);
224
+ expect(parsed.failed).toBe(1);
225
+ expect(parsed.results.failed[0].emailId).toBe('email-2');
226
+ });
227
+ it('returns error when JMAP method fails', async () => {
228
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
229
+ methodResponses: [
230
+ ['error', { type: 'serverFail' }, 'batchMarkUnread'],
231
+ ],
232
+ });
233
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
234
+ success: false,
235
+ error: { type: 'serverFail' },
236
+ });
237
+ const handler = registeredTools.get('batch_mark_unread');
238
+ const result = await handler({ emailIds: ['email-1'] });
239
+ expect(result.isError).toBe(true);
240
+ expect(result.content[0].text).toContain('Failed to batch mark emails as unread');
241
+ });
242
+ });
243
+ describe('batch_move', () => {
244
+ it('returns success when all emails are moved', async () => {
245
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
246
+ methodResponses: [
247
+ [
248
+ 'Email/set',
249
+ {
250
+ accountId: 'account-1',
251
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
252
+ },
253
+ 'batchMove',
254
+ ],
255
+ ],
256
+ });
257
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
258
+ success: true,
259
+ data: {
260
+ accountId: 'account-1',
261
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
262
+ },
263
+ });
264
+ const handler = registeredTools.get('batch_move');
265
+ const result = await handler({
266
+ emailIds: ['email-1', 'email-2', 'email-3'],
267
+ targetMailboxId: 'archive-mailbox',
268
+ });
269
+ expect(result.isError).toBeUndefined();
270
+ const parsed = JSON.parse(result.content[0].text);
271
+ expect(parsed).toEqual({
272
+ success: true,
273
+ total: 3,
274
+ succeeded: 3,
275
+ failed: 0,
276
+ results: {
277
+ succeeded: ['email-1', 'email-2', 'email-3'],
278
+ failed: [],
279
+ },
280
+ targetMailboxId: 'archive-mailbox',
281
+ });
282
+ });
283
+ it('handles partial failure with some emails not found', async () => {
284
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
285
+ methodResponses: [
286
+ [
287
+ 'Email/set',
288
+ {
289
+ accountId: 'account-1',
290
+ updated: { 'email-1': null },
291
+ notUpdated: {
292
+ 'email-2': { type: 'notFound', description: 'Email not found' },
293
+ 'email-3': { type: 'notFound', description: 'Email not found' },
294
+ },
295
+ },
296
+ 'batchMove',
297
+ ],
298
+ ],
299
+ });
300
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
301
+ success: true,
302
+ data: {
303
+ accountId: 'account-1',
304
+ updated: { 'email-1': null },
305
+ notUpdated: {
306
+ 'email-2': { type: 'notFound', description: 'Email not found' },
307
+ 'email-3': { type: 'notFound', description: 'Email not found' },
308
+ },
309
+ },
310
+ });
311
+ const handler = registeredTools.get('batch_move');
312
+ const result = await handler({
313
+ emailIds: ['email-1', 'email-2', 'email-3'],
314
+ targetMailboxId: 'archive-mailbox',
315
+ });
316
+ expect(result.isError).toBeUndefined();
317
+ const parsed = JSON.parse(result.content[0].text);
318
+ expect(parsed.success).toBe(false);
319
+ expect(parsed.total).toBe(3);
320
+ expect(parsed.succeeded).toBe(1);
321
+ expect(parsed.failed).toBe(2);
322
+ expect(parsed.targetMailboxId).toBe('archive-mailbox');
323
+ expect(parsed.results.failed.length).toBe(2);
324
+ });
325
+ it('includes targetMailboxId in request', async () => {
326
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
327
+ methodResponses: [
328
+ ['Email/set', { accountId: 'account-1', updated: { 'email-1': null } }, 'batchMove'],
329
+ ],
330
+ });
331
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
332
+ success: true,
333
+ data: { accountId: 'account-1', updated: { 'email-1': null } },
334
+ });
335
+ const handler = registeredTools.get('batch_move');
336
+ await handler({ emailIds: ['email-1'], targetMailboxId: 'target-123' });
337
+ // Verify the request was made with correct mailboxIds structure
338
+ expect(mockJmapClient.request).toHaveBeenCalledWith([
339
+ [
340
+ 'Email/set',
341
+ {
342
+ accountId: 'account-1',
343
+ update: {
344
+ 'email-1': { mailboxIds: { 'target-123': true } },
345
+ },
346
+ },
347
+ 'batchMove',
348
+ ],
349
+ ]);
350
+ });
351
+ it('returns error when JMAP method fails', async () => {
352
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
353
+ methodResponses: [
354
+ ['error', { type: 'invalidArguments', description: 'Invalid mailbox ID' }, 'batchMove'],
355
+ ],
356
+ });
357
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
358
+ success: false,
359
+ error: { type: 'invalidArguments', description: 'Invalid mailbox ID' },
360
+ });
361
+ const handler = registeredTools.get('batch_move');
362
+ const result = await handler({
363
+ emailIds: ['email-1'],
364
+ targetMailboxId: 'invalid-mailbox',
365
+ });
366
+ expect(result.isError).toBe(true);
367
+ expect(result.content[0].text).toContain('Failed to batch move emails');
368
+ });
369
+ });
370
+ describe('batch_delete', () => {
371
+ it('returns success when all emails are permanently deleted', async () => {
372
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
373
+ methodResponses: [
374
+ [
375
+ 'Email/set',
376
+ {
377
+ accountId: 'account-1',
378
+ destroyed: ['email-1', 'email-2', 'email-3'],
379
+ },
380
+ 'batchDestroy',
381
+ ],
382
+ ],
383
+ });
384
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
385
+ success: true,
386
+ data: {
387
+ accountId: 'account-1',
388
+ destroyed: ['email-1', 'email-2', 'email-3'],
389
+ },
390
+ });
391
+ const handler = registeredTools.get('batch_delete');
392
+ const result = await handler({ emailIds: ['email-1', 'email-2', 'email-3'], permanent: true });
393
+ expect(result.isError).toBeUndefined();
394
+ const parsed = JSON.parse(result.content[0].text);
395
+ expect(parsed).toEqual({
396
+ success: true,
397
+ total: 3,
398
+ succeeded: 3,
399
+ failed: 0,
400
+ results: {
401
+ succeeded: ['email-1', 'email-2', 'email-3'],
402
+ failed: [],
403
+ },
404
+ action: 'permanently_deleted',
405
+ });
406
+ });
407
+ it('handles partial failure with some emails not destroyed', async () => {
408
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
409
+ methodResponses: [
410
+ [
411
+ 'Email/set',
412
+ {
413
+ accountId: 'account-1',
414
+ destroyed: ['email-1'],
415
+ notDestroyed: {
416
+ 'email-2': { type: 'notFound', description: 'Email not found' },
417
+ },
418
+ },
419
+ 'batchDestroy',
420
+ ],
421
+ ],
422
+ });
423
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
424
+ success: true,
425
+ data: {
426
+ accountId: 'account-1',
427
+ destroyed: ['email-1'],
428
+ notDestroyed: {
429
+ 'email-2': { type: 'notFound', description: 'Email not found' },
430
+ },
431
+ },
432
+ });
433
+ const handler = registeredTools.get('batch_delete');
434
+ const result = await handler({ emailIds: ['email-1', 'email-2'], permanent: true });
435
+ expect(result.isError).toBeUndefined();
436
+ const parsed = JSON.parse(result.content[0].text);
437
+ expect(parsed.success).toBe(false);
438
+ expect(parsed.succeeded).toBe(1);
439
+ expect(parsed.failed).toBe(1);
440
+ expect(parsed.action).toBe('permanently_deleted');
441
+ expect(parsed.results.failed[0].emailId).toBe('email-2');
442
+ });
443
+ it('moves emails to Trash when permanent=false', async () => {
444
+ // First call: Mailbox/query to find Trash
445
+ // Second call: Email/set to move to Trash
446
+ vi.mocked(mockJmapClient.request)
447
+ .mockResolvedValueOnce({
448
+ methodResponses: [
449
+ ['Mailbox/query', { ids: ['trash-mailbox-id'] }, 'findTrash'],
450
+ ],
451
+ })
452
+ .mockResolvedValueOnce({
453
+ methodResponses: [
454
+ [
455
+ 'Email/set',
456
+ {
457
+ accountId: 'account-1',
458
+ updated: { 'email-1': null, 'email-2': null },
459
+ },
460
+ 'batchMoveToTrash',
461
+ ],
462
+ ],
463
+ });
464
+ vi.mocked(mockJmapClient.parseMethodResponse)
465
+ .mockReturnValueOnce({
466
+ success: true,
467
+ data: { ids: ['trash-mailbox-id'] },
468
+ })
469
+ .mockReturnValueOnce({
470
+ success: true,
471
+ data: {
472
+ accountId: 'account-1',
473
+ updated: { 'email-1': null, 'email-2': null },
474
+ },
475
+ });
476
+ const handler = registeredTools.get('batch_delete');
477
+ const result = await handler({ emailIds: ['email-1', 'email-2'], permanent: false });
478
+ expect(result.isError).toBeUndefined();
479
+ const parsed = JSON.parse(result.content[0].text);
480
+ expect(parsed.success).toBe(true);
481
+ expect(parsed.action).toBe('moved_to_trash');
482
+ expect(parsed.succeeded).toBe(2);
483
+ });
484
+ it('falls back to permanent delete when no Trash mailbox exists', async () => {
485
+ // First call: Mailbox/query returns empty
486
+ // Second call: Email/set with destroy
487
+ vi.mocked(mockJmapClient.request)
488
+ .mockResolvedValueOnce({
489
+ methodResponses: [
490
+ ['Mailbox/query', { ids: [] }, 'findTrash'],
491
+ ],
492
+ })
493
+ .mockResolvedValueOnce({
494
+ methodResponses: [
495
+ [
496
+ 'Email/set',
497
+ {
498
+ accountId: 'account-1',
499
+ destroyed: ['email-1', 'email-2'],
500
+ },
501
+ 'batchDestroyFallback',
502
+ ],
503
+ ],
504
+ });
505
+ vi.mocked(mockJmapClient.parseMethodResponse)
506
+ .mockReturnValueOnce({
507
+ success: true,
508
+ data: { ids: [] },
509
+ })
510
+ .mockReturnValueOnce({
511
+ success: true,
512
+ data: {
513
+ accountId: 'account-1',
514
+ destroyed: ['email-1', 'email-2'],
515
+ },
516
+ });
517
+ const handler = registeredTools.get('batch_delete');
518
+ const result = await handler({ emailIds: ['email-1', 'email-2'], permanent: false });
519
+ expect(result.isError).toBeUndefined();
520
+ const parsed = JSON.parse(result.content[0].text);
521
+ expect(parsed.success).toBe(true);
522
+ expect(parsed.action).toBe('permanently_deleted');
523
+ expect(mockLogger.warn).toHaveBeenCalled();
524
+ });
525
+ it('queries Trash mailbox only once for batch operation', async () => {
526
+ vi.mocked(mockJmapClient.request)
527
+ .mockResolvedValueOnce({
528
+ methodResponses: [
529
+ ['Mailbox/query', { ids: ['trash-mailbox-id'] }, 'findTrash'],
530
+ ],
531
+ })
532
+ .mockResolvedValueOnce({
533
+ methodResponses: [
534
+ [
535
+ 'Email/set',
536
+ {
537
+ accountId: 'account-1',
538
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
539
+ },
540
+ 'batchMoveToTrash',
541
+ ],
542
+ ],
543
+ });
544
+ vi.mocked(mockJmapClient.parseMethodResponse)
545
+ .mockReturnValueOnce({
546
+ success: true,
547
+ data: { ids: ['trash-mailbox-id'] },
548
+ })
549
+ .mockReturnValueOnce({
550
+ success: true,
551
+ data: {
552
+ accountId: 'account-1',
553
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
554
+ },
555
+ });
556
+ const handler = registeredTools.get('batch_delete');
557
+ await handler({ emailIds: ['email-1', 'email-2', 'email-3'], permanent: false });
558
+ // Exactly 2 requests: 1 for Trash query, 1 for Email/set
559
+ expect(mockJmapClient.request).toHaveBeenCalledTimes(2);
560
+ });
561
+ it('returns error when JMAP method fails', async () => {
562
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
563
+ methodResponses: [
564
+ ['error', { type: 'serverFail', description: 'Server error' }, 'batchDestroy'],
565
+ ],
566
+ });
567
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
568
+ success: false,
569
+ error: { type: 'serverFail', description: 'Server error' },
570
+ });
571
+ const handler = registeredTools.get('batch_delete');
572
+ const result = await handler({ emailIds: ['email-1'], permanent: true });
573
+ expect(result.isError).toBe(true);
574
+ expect(result.content[0].text).toContain('Failed to batch delete emails');
575
+ });
576
+ });
577
+ describe('batch_add_label', () => {
578
+ it('returns success when label is added to all emails', async () => {
579
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
580
+ methodResponses: [
581
+ [
582
+ 'Email/set',
583
+ {
584
+ accountId: 'account-1',
585
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
586
+ },
587
+ 'batchAddLabel',
588
+ ],
589
+ ],
590
+ });
591
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
592
+ success: true,
593
+ data: {
594
+ accountId: 'account-1',
595
+ updated: { 'email-1': null, 'email-2': null, 'email-3': null },
596
+ },
597
+ });
598
+ const handler = registeredTools.get('batch_add_label');
599
+ const result = await handler({
600
+ emailIds: ['email-1', 'email-2', 'email-3'],
601
+ mailboxId: 'important-label',
602
+ });
603
+ expect(result.isError).toBeUndefined();
604
+ const parsed = JSON.parse(result.content[0].text);
605
+ expect(parsed).toEqual({
606
+ success: true,
607
+ total: 3,
608
+ succeeded: 3,
609
+ failed: 0,
610
+ results: {
611
+ succeeded: ['email-1', 'email-2', 'email-3'],
612
+ failed: [],
613
+ },
614
+ mailboxId: 'important-label',
615
+ });
616
+ });
617
+ it('handles partial failure with some emails not found', async () => {
618
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
619
+ methodResponses: [
620
+ [
621
+ 'Email/set',
622
+ {
623
+ accountId: 'account-1',
624
+ updated: { 'email-1': null },
625
+ notUpdated: {
626
+ 'email-2': { type: 'notFound', description: 'Email not found' },
627
+ },
628
+ },
629
+ 'batchAddLabel',
630
+ ],
631
+ ],
632
+ });
633
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
634
+ success: true,
635
+ data: {
636
+ accountId: 'account-1',
637
+ updated: { 'email-1': null },
638
+ notUpdated: {
639
+ 'email-2': { type: 'notFound', description: 'Email not found' },
640
+ },
641
+ },
642
+ });
643
+ const handler = registeredTools.get('batch_add_label');
644
+ const result = await handler({
645
+ emailIds: ['email-1', 'email-2'],
646
+ mailboxId: 'label-123',
647
+ });
648
+ expect(result.isError).toBeUndefined();
649
+ const parsed = JSON.parse(result.content[0].text);
650
+ expect(parsed.success).toBe(false);
651
+ expect(parsed.succeeded).toBe(1);
652
+ expect(parsed.failed).toBe(1);
653
+ expect(parsed.mailboxId).toBe('label-123');
654
+ });
655
+ it('uses correct path syntax for mailboxId', async () => {
656
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
657
+ methodResponses: [
658
+ ['Email/set', { accountId: 'account-1', updated: { 'email-1': null } }, 'batchAddLabel'],
659
+ ],
660
+ });
661
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
662
+ success: true,
663
+ data: { accountId: 'account-1', updated: { 'email-1': null } },
664
+ });
665
+ const handler = registeredTools.get('batch_add_label');
666
+ await handler({ emailIds: ['email-1'], mailboxId: 'label-xyz' });
667
+ // Verify path syntax: mailboxIds/[mailboxId]
668
+ expect(mockJmapClient.request).toHaveBeenCalledWith([
669
+ [
670
+ 'Email/set',
671
+ {
672
+ accountId: 'account-1',
673
+ update: {
674
+ 'email-1': { 'mailboxIds/label-xyz': true },
675
+ },
676
+ },
677
+ 'batchAddLabel',
678
+ ],
679
+ ]);
680
+ });
681
+ it('returns error when JMAP method fails', async () => {
682
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
683
+ methodResponses: [
684
+ ['error', { type: 'invalidArguments', description: 'Invalid mailbox' }, 'batchAddLabel'],
685
+ ],
686
+ });
687
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
688
+ success: false,
689
+ error: { type: 'invalidArguments', description: 'Invalid mailbox' },
690
+ });
691
+ const handler = registeredTools.get('batch_add_label');
692
+ const result = await handler({ emailIds: ['email-1'], mailboxId: 'invalid' });
693
+ expect(result.isError).toBe(true);
694
+ expect(result.content[0].text).toContain('Failed to batch add label');
695
+ });
696
+ });
697
+ describe('batch_remove_label', () => {
698
+ it('returns success when label is removed from all emails', async () => {
699
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
700
+ methodResponses: [
701
+ [
702
+ 'Email/set',
703
+ {
704
+ accountId: 'account-1',
705
+ updated: { 'email-1': null, 'email-2': null },
706
+ },
707
+ 'batchRemoveLabel',
708
+ ],
709
+ ],
710
+ });
711
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
712
+ success: true,
713
+ data: {
714
+ accountId: 'account-1',
715
+ updated: { 'email-1': null, 'email-2': null },
716
+ },
717
+ });
718
+ const handler = registeredTools.get('batch_remove_label');
719
+ const result = await handler({
720
+ emailIds: ['email-1', 'email-2'],
721
+ mailboxId: 'old-label',
722
+ });
723
+ expect(result.isError).toBeUndefined();
724
+ const parsed = JSON.parse(result.content[0].text);
725
+ expect(parsed).toEqual({
726
+ success: true,
727
+ total: 2,
728
+ succeeded: 2,
729
+ failed: 0,
730
+ results: {
731
+ succeeded: ['email-1', 'email-2'],
732
+ failed: [],
733
+ },
734
+ mailboxId: 'old-label',
735
+ });
736
+ });
737
+ it('handles partial failure', async () => {
738
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
739
+ methodResponses: [
740
+ [
741
+ 'Email/set',
742
+ {
743
+ accountId: 'account-1',
744
+ updated: { 'email-1': null },
745
+ notUpdated: {
746
+ 'email-2': { type: 'invalidProperties', description: 'Cannot remove last mailbox' },
747
+ },
748
+ },
749
+ 'batchRemoveLabel',
750
+ ],
751
+ ],
752
+ });
753
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
754
+ success: true,
755
+ data: {
756
+ accountId: 'account-1',
757
+ updated: { 'email-1': null },
758
+ notUpdated: {
759
+ 'email-2': { type: 'invalidProperties', description: 'Cannot remove last mailbox' },
760
+ },
761
+ },
762
+ });
763
+ const handler = registeredTools.get('batch_remove_label');
764
+ const result = await handler({
765
+ emailIds: ['email-1', 'email-2'],
766
+ mailboxId: 'label-123',
767
+ });
768
+ expect(result.isError).toBeUndefined();
769
+ const parsed = JSON.parse(result.content[0].text);
770
+ expect(parsed.success).toBe(false);
771
+ expect(parsed.succeeded).toBe(1);
772
+ expect(parsed.failed).toBe(1);
773
+ expect(parsed.results.failed[0].error).toContain('invalidProperties');
774
+ });
775
+ it('uses correct path syntax for mailboxId removal', async () => {
776
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
777
+ methodResponses: [
778
+ ['Email/set', { accountId: 'account-1', updated: { 'email-1': null } }, 'batchRemoveLabel'],
779
+ ],
780
+ });
781
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
782
+ success: true,
783
+ data: { accountId: 'account-1', updated: { 'email-1': null } },
784
+ });
785
+ const handler = registeredTools.get('batch_remove_label');
786
+ await handler({ emailIds: ['email-1'], mailboxId: 'label-xyz' });
787
+ // Verify path syntax with null value: mailboxIds/[mailboxId]: null
788
+ expect(mockJmapClient.request).toHaveBeenCalledWith([
789
+ [
790
+ 'Email/set',
791
+ {
792
+ accountId: 'account-1',
793
+ update: {
794
+ 'email-1': { 'mailboxIds/label-xyz': null },
795
+ },
796
+ },
797
+ 'batchRemoveLabel',
798
+ ],
799
+ ]);
800
+ });
801
+ it('returns error when JMAP method fails', async () => {
802
+ vi.mocked(mockJmapClient.request).mockResolvedValue({
803
+ methodResponses: [
804
+ ['error', { type: 'serverFail' }, 'batchRemoveLabel'],
805
+ ],
806
+ });
807
+ vi.mocked(mockJmapClient.parseMethodResponse).mockReturnValue({
808
+ success: false,
809
+ error: { type: 'serverFail' },
810
+ });
811
+ const handler = registeredTools.get('batch_remove_label');
812
+ const result = await handler({ emailIds: ['email-1'], mailboxId: 'label' });
813
+ expect(result.isError).toBe(true);
814
+ expect(result.content[0].text).toContain('Failed to batch remove label');
815
+ });
816
+ });
817
+ describe('tool registration', () => {
818
+ it('registers all 6 batch operation tools', () => {
819
+ expect(registeredTools.has('batch_mark_read')).toBe(true);
820
+ expect(registeredTools.has('batch_mark_unread')).toBe(true);
821
+ expect(registeredTools.has('batch_move')).toBe(true);
822
+ expect(registeredTools.has('batch_delete')).toBe(true);
823
+ expect(registeredTools.has('batch_add_label')).toBe(true);
824
+ expect(registeredTools.has('batch_remove_label')).toBe(true);
825
+ expect(registeredTools.size).toBe(6);
826
+ });
827
+ });
828
+ describe('error handling', () => {
829
+ it('handles exceptions in batch_mark_read', async () => {
830
+ vi.mocked(mockJmapClient.request).mockRejectedValue(new Error('Network error'));
831
+ const handler = registeredTools.get('batch_mark_read');
832
+ const result = await handler({ emailIds: ['email-1'] });
833
+ expect(result.isError).toBe(true);
834
+ expect(result.content[0].text).toContain('Error batch marking emails as read');
835
+ expect(result.content[0].text).toContain('Network error');
836
+ });
837
+ it('handles exceptions in batch_mark_unread', async () => {
838
+ vi.mocked(mockJmapClient.request).mockRejectedValue(new Error('Connection failed'));
839
+ const handler = registeredTools.get('batch_mark_unread');
840
+ const result = await handler({ emailIds: ['email-1'] });
841
+ expect(result.isError).toBe(true);
842
+ expect(result.content[0].text).toContain('Error batch marking emails as unread');
843
+ });
844
+ it('handles exceptions in batch_move', async () => {
845
+ vi.mocked(mockJmapClient.request).mockRejectedValue(new Error('Timeout'));
846
+ const handler = registeredTools.get('batch_move');
847
+ const result = await handler({ emailIds: ['email-1'], targetMailboxId: 'mailbox-1' });
848
+ expect(result.isError).toBe(true);
849
+ expect(result.content[0].text).toContain('Error batch moving emails');
850
+ });
851
+ it('handles exceptions in batch_delete', async () => {
852
+ vi.mocked(mockJmapClient.request).mockRejectedValue(new Error('Connection reset'));
853
+ const handler = registeredTools.get('batch_delete');
854
+ const result = await handler({ emailIds: ['email-1'], permanent: true });
855
+ expect(result.isError).toBe(true);
856
+ expect(result.content[0].text).toContain('Error batch deleting emails');
857
+ expect(result.content[0].text).toContain('Connection reset');
858
+ });
859
+ it('handles exceptions in batch_add_label', async () => {
860
+ vi.mocked(mockJmapClient.request).mockRejectedValue(new Error('Service unavailable'));
861
+ const handler = registeredTools.get('batch_add_label');
862
+ const result = await handler({ emailIds: ['email-1'], mailboxId: 'label-1' });
863
+ expect(result.isError).toBe(true);
864
+ expect(result.content[0].text).toContain('Error batch adding label');
865
+ });
866
+ it('handles exceptions in batch_remove_label', async () => {
867
+ vi.mocked(mockJmapClient.request).mockRejectedValue(new Error('Gateway timeout'));
868
+ const handler = registeredTools.get('batch_remove_label');
869
+ const result = await handler({ emailIds: ['email-1'], mailboxId: 'label-1' });
870
+ expect(result.isError).toBe(true);
871
+ expect(result.content[0].text).toContain('Error batch removing label');
872
+ });
873
+ });
874
+ });
875
+ //# sourceMappingURL=batch-operations.test.js.map