m365-agent-cli 1.2.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.
Files changed (92) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +916 -0
  3. package/package.json +50 -0
  4. package/src/cli.ts +100 -0
  5. package/src/commands/auto-reply.ts +182 -0
  6. package/src/commands/calendar.ts +576 -0
  7. package/src/commands/counter.ts +87 -0
  8. package/src/commands/create-event.ts +544 -0
  9. package/src/commands/delegates.ts +286 -0
  10. package/src/commands/delete-event.ts +321 -0
  11. package/src/commands/drafts.ts +502 -0
  12. package/src/commands/files.ts +532 -0
  13. package/src/commands/find.ts +195 -0
  14. package/src/commands/findtime.ts +270 -0
  15. package/src/commands/folders.ts +177 -0
  16. package/src/commands/forward-event.ts +49 -0
  17. package/src/commands/graph-calendar.ts +217 -0
  18. package/src/commands/login.ts +195 -0
  19. package/src/commands/mail.ts +950 -0
  20. package/src/commands/oof.ts +263 -0
  21. package/src/commands/outlook-categories.ts +173 -0
  22. package/src/commands/outlook-graph.ts +880 -0
  23. package/src/commands/planner.ts +1678 -0
  24. package/src/commands/respond.ts +291 -0
  25. package/src/commands/rooms.ts +210 -0
  26. package/src/commands/rules.ts +511 -0
  27. package/src/commands/schedule.ts +109 -0
  28. package/src/commands/send.ts +204 -0
  29. package/src/commands/serve.ts +14 -0
  30. package/src/commands/sharepoint.ts +179 -0
  31. package/src/commands/site-pages.ts +163 -0
  32. package/src/commands/subscribe.ts +103 -0
  33. package/src/commands/subscriptions.ts +29 -0
  34. package/src/commands/suggest.ts +155 -0
  35. package/src/commands/todo.ts +2092 -0
  36. package/src/commands/update-event.ts +608 -0
  37. package/src/commands/update.ts +88 -0
  38. package/src/commands/verify-token.ts +62 -0
  39. package/src/commands/whoami.ts +74 -0
  40. package/src/index.ts +190 -0
  41. package/src/lib/atomic-write.ts +20 -0
  42. package/src/lib/attach-link-spec.test.ts +24 -0
  43. package/src/lib/attach-link-spec.ts +70 -0
  44. package/src/lib/attachments.ts +79 -0
  45. package/src/lib/auth.ts +192 -0
  46. package/src/lib/calendar-range.test.ts +41 -0
  47. package/src/lib/calendar-range.ts +103 -0
  48. package/src/lib/dates.test.ts +74 -0
  49. package/src/lib/dates.ts +137 -0
  50. package/src/lib/delegate-client.test.ts +74 -0
  51. package/src/lib/delegate-client.ts +322 -0
  52. package/src/lib/ews-client.ts +3418 -0
  53. package/src/lib/git-commit.ts +4 -0
  54. package/src/lib/glitchtip-eligibility.ts +220 -0
  55. package/src/lib/glitchtip.ts +253 -0
  56. package/src/lib/global-env.ts +3 -0
  57. package/src/lib/graph-auth.ts +223 -0
  58. package/src/lib/graph-calendar-client.test.ts +118 -0
  59. package/src/lib/graph-calendar-client.ts +112 -0
  60. package/src/lib/graph-client.test.ts +107 -0
  61. package/src/lib/graph-client.ts +1058 -0
  62. package/src/lib/graph-constants.ts +12 -0
  63. package/src/lib/graph-directory.ts +116 -0
  64. package/src/lib/graph-event.ts +134 -0
  65. package/src/lib/graph-schedule.ts +173 -0
  66. package/src/lib/graph-subscriptions.ts +94 -0
  67. package/src/lib/graph-user-path.ts +13 -0
  68. package/src/lib/jwt-utils.ts +34 -0
  69. package/src/lib/markdown.test.ts +21 -0
  70. package/src/lib/markdown.ts +174 -0
  71. package/src/lib/mime-type.ts +106 -0
  72. package/src/lib/oof-client.test.ts +59 -0
  73. package/src/lib/oof-client.ts +122 -0
  74. package/src/lib/outlook-graph-client.test.ts +146 -0
  75. package/src/lib/outlook-graph-client.ts +649 -0
  76. package/src/lib/outlook-master-categories.ts +145 -0
  77. package/src/lib/package-info.ts +59 -0
  78. package/src/lib/places-client.ts +144 -0
  79. package/src/lib/planner-client.ts +1226 -0
  80. package/src/lib/rules-client.ts +178 -0
  81. package/src/lib/sharepoint-client.ts +101 -0
  82. package/src/lib/site-pages-client.ts +73 -0
  83. package/src/lib/todo-client.test.ts +298 -0
  84. package/src/lib/todo-client.ts +1309 -0
  85. package/src/lib/url-validation.ts +40 -0
  86. package/src/lib/utils.ts +45 -0
  87. package/src/lib/webhook-server.ts +51 -0
  88. package/src/test/auth.test.ts +104 -0
  89. package/src/test/cli.integration.test.ts +1083 -0
  90. package/src/test/ews-client.test.ts +268 -0
  91. package/src/test/mocks/index.ts +375 -0
  92. package/src/test/mocks/responses.ts +861 -0
@@ -0,0 +1,950 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { extname, join } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { AttachmentLinkSpecError, parseAttachLinkSpec } from '../lib/attach-link-spec.js';
5
+ import { AttachmentPathError, validateAttachmentPath } from '../lib/attachments.js';
6
+ import { resolveAuth } from '../lib/auth.js';
7
+ import { parseDay, toLocalUnzonedISOString } from '../lib/dates.js';
8
+ import {
9
+ addAttachmentToDraft,
10
+ addReferenceAttachmentToDraft,
11
+ forwardEmail,
12
+ forwardEmailDraft,
13
+ getAttachment,
14
+ getAttachments,
15
+ getEmail,
16
+ getEmails,
17
+ getMailFolders,
18
+ moveEmail,
19
+ replyToEmail,
20
+ replyToEmailDraft,
21
+ SENSITIVITY_MAP,
22
+ sendDraftById,
23
+ updateDraft,
24
+ updateEmail
25
+ } from '../lib/ews-client.js';
26
+ import { markdownToHtml } from '../lib/markdown.js';
27
+ import { lookupMimeType } from '../lib/mime-type.js';
28
+ import { checkReadOnly } from '../lib/utils.js';
29
+
30
+ function formatDate(dateStr: string): string {
31
+ const date = new Date(dateStr);
32
+ const now = new Date();
33
+ const isToday = date.toDateString() === now.toDateString();
34
+ const yesterday = new Date(now);
35
+ yesterday.setDate(yesterday.getDate() - 1);
36
+ const isYesterday = date.toDateString() === yesterday.toDateString();
37
+
38
+ if (isToday) {
39
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
40
+ } else if (isYesterday) {
41
+ return 'Yesterday';
42
+ } else {
43
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
44
+ }
45
+ }
46
+
47
+ function truncate(str: string, maxLen: number): string {
48
+ if (!str) return '';
49
+ str = str.replace(/\s+/g, ' ').trim();
50
+ if (str.length <= maxLen) return str;
51
+ return `${str.substring(0, maxLen - 1)}\u2026`;
52
+ }
53
+
54
+ async function applyDraftCategoriesAttachments(
55
+ token: string,
56
+ draftId: string,
57
+ mailbox: string | undefined,
58
+ opts: {
59
+ withCategory?: string[];
60
+ attach?: string;
61
+ attachLink?: string[];
62
+ json?: boolean;
63
+ }
64
+ ): Promise<void> {
65
+ const cats = (opts.withCategory ?? []).map((c) => c.trim()).filter(Boolean);
66
+ if (cats.length > 0) {
67
+ const r = await updateDraft(token, draftId, { categories: cats, mailbox });
68
+ if (!r.ok) {
69
+ console.error(`Error: ${r.error?.message || 'Failed to set categories on draft'}`);
70
+ process.exit(1);
71
+ }
72
+ }
73
+ const wd = process.cwd();
74
+ if (opts.attach) {
75
+ const filePaths = opts.attach
76
+ .split(',')
77
+ .map((f) => f.trim())
78
+ .filter(Boolean);
79
+ for (const filePath of filePaths) {
80
+ try {
81
+ const validated = await validateAttachmentPath(filePath, wd);
82
+ const content = await readFile(validated.absolutePath);
83
+ const contentType = lookupMimeType(validated.fileName) || 'application/octet-stream';
84
+ const ar = await addAttachmentToDraft(
85
+ token,
86
+ draftId,
87
+ { name: validated.fileName, contentType, contentBytes: content.toString('base64') },
88
+ mailbox
89
+ );
90
+ if (!ar.ok) {
91
+ console.error(`Failed to attach ${validated.fileName}: ${ar.error?.message}`);
92
+ process.exit(1);
93
+ }
94
+ if (!opts.json) console.log(` Attached: ${validated.fileName}`);
95
+ } catch (err) {
96
+ if (err instanceof AttachmentPathError) {
97
+ console.error(err.message);
98
+ } else {
99
+ console.error(`Failed to read attachment: ${filePath}`);
100
+ }
101
+ process.exit(1);
102
+ }
103
+ }
104
+ }
105
+ for (const spec of opts.attachLink ?? []) {
106
+ try {
107
+ const { name, url } = parseAttachLinkSpec(spec);
108
+ const linkRes = await addReferenceAttachmentToDraft(
109
+ token,
110
+ draftId,
111
+ { name, url, contentType: 'text/html' },
112
+ mailbox
113
+ );
114
+ if (!linkRes.ok) {
115
+ console.error(`Failed to attach link ${name}: ${linkRes.error?.message}`);
116
+ process.exit(1);
117
+ }
118
+ if (!opts.json) console.log(` Attached link: ${name}`);
119
+ } catch (err) {
120
+ const msg =
121
+ err instanceof AttachmentLinkSpecError ? err.message : err instanceof Error ? err.message : String(err);
122
+ console.error(`Invalid --attach-link: ${msg}`);
123
+ process.exit(1);
124
+ }
125
+ }
126
+ }
127
+
128
+ export const mailCommand = new Command('mail')
129
+ .description('List and read emails')
130
+ .argument('[folder]', 'Folder: inbox, sent, drafts, deleted, archive, junk', 'inbox')
131
+ .option('-n, --limit <number>', 'Number of emails to show', '10')
132
+ .option('-p, --page <number>', 'Page number (1-based)', '1')
133
+ .option('--unread', 'Show only unread emails')
134
+ .option('--flagged', 'Show only flagged emails')
135
+ .option('-s, --search <query>', 'Search emails (subject, body, sender)')
136
+ .option('-r, --read <id>', 'Read email by ID')
137
+ .option('-d, --download <id>', 'Download attachments from email by ID')
138
+ .option('-o, --output <dir>', 'Output directory for attachments', '.')
139
+ .option('--mark-read <id>', 'Mark email as read (by ID)')
140
+ .option('--mark-unread <id>', 'Mark email as unread (by ID)')
141
+ .option('--flag <id>', 'Flag email (by ID)')
142
+ .option('--start-date <date>', 'Start date for flag (YYYY-MM-DD)')
143
+ .option('--due <date>', 'Due date for flag (YYYY-MM-DD)')
144
+ .option('--unflag <id>', 'Remove flag (by ID)')
145
+ .option('--complete <id>', 'Mark flagged email as complete (by ID)')
146
+ .option('--sensitivity <id>', 'Set sensitivity on email by ID (use with --level)')
147
+ .option('--level <level>', 'Sensitivity level: normal, personal, private, confidential')
148
+ .option('--move <id>', 'Move email to folder (use with --to)')
149
+ .option('--to <folder>', 'Destination folder for move (inbox, archive, deleted, junk)')
150
+ .option('--reply <id>', 'Reply to email by ID')
151
+ .option('--reply-all <id>', 'Reply all to email by ID')
152
+ .option('--draft', 'Create a reply or forward draft (do not send); use with --reply, --reply-all, or --forward')
153
+ .option('--forward <id>', 'Forward email by ID (use with --to-addr)')
154
+ .option('--to-addr <emails>', 'Forward recipients (comma-separated)')
155
+ .option('--message <text>', 'Reply/forward message text')
156
+ .option('--attach <files>', 'On reply/forward: comma-separated file paths (uses draft + send)')
157
+ .option(
158
+ '--attach-link <spec>',
159
+ 'On reply/forward: link attachment (repeatable)',
160
+ (v: string, prev: string[]) => [...prev, v],
161
+ [] as string[]
162
+ )
163
+ .option(
164
+ '--with-category <name>',
165
+ 'On reply/forward: Outlook category on outgoing message (repeatable; not for --set-categories)',
166
+ (v: string, prev: string[]) => [...prev, v],
167
+ [] as string[]
168
+ )
169
+ .option('--markdown', 'Parse message as markdown (bold, links, lists)')
170
+ .option('--json', 'Output as JSON')
171
+ .option('--token <token>', 'Use a specific token')
172
+ .option(
173
+ '--mailbox <email>',
174
+ 'Delegated or shared mailbox (list, read, move, flags, attachments, reply, forward; X-AnchorMailbox)'
175
+ )
176
+ .option('--identity <name>', 'Use a specific authentication identity (default: default)')
177
+ .option('--set-categories <id>', 'Set categories on message (use with --category, repeatable)')
178
+ .option('--clear-categories <id>', 'Remove all categories from message')
179
+ .option(
180
+ '--category <name>',
181
+ 'Category label (repeatable; use with --set-categories)',
182
+ (v: string, prev: string[]) => [...prev, v],
183
+ [] as string[]
184
+ )
185
+ .action(
186
+ async (
187
+ folder: string,
188
+ options: {
189
+ limit: string;
190
+ page: string;
191
+ unread?: boolean;
192
+ flagged?: boolean;
193
+ search?: string;
194
+ read?: string;
195
+ download?: string;
196
+ output: string;
197
+ force?: boolean;
198
+ markRead?: string;
199
+ markUnread?: string;
200
+ flag?: string;
201
+ startDate?: string;
202
+ due?: string;
203
+ unflag?: string;
204
+ complete?: string;
205
+ sensitivity?: string;
206
+ level?: string;
207
+ move?: string;
208
+ to?: string;
209
+ reply?: string;
210
+ replyAll?: string;
211
+ forward?: string;
212
+ toAddr?: string;
213
+ message?: string;
214
+ markdown?: boolean;
215
+ json?: boolean;
216
+ token?: string;
217
+ draft?: boolean;
218
+ mailbox?: string;
219
+ identity?: string;
220
+ setCategories?: string;
221
+ clearCategories?: string;
222
+ category?: string[];
223
+ attach?: string;
224
+ attachLink?: string[];
225
+ withCategory?: string[];
226
+ },
227
+ cmd: any
228
+ ) => {
229
+ const isMutating =
230
+ options.flag ||
231
+ options.unflag ||
232
+ options.markRead ||
233
+ options.markUnread ||
234
+ options.complete ||
235
+ options.sensitivity ||
236
+ options.move ||
237
+ options.reply ||
238
+ options.replyAll ||
239
+ options.forward ||
240
+ options.setCategories ||
241
+ options.clearCategories;
242
+
243
+ if (isMutating) {
244
+ checkReadOnly(cmd);
245
+ }
246
+ const authResult = await resolveAuth({
247
+ token: options.token,
248
+ identity: options.identity
249
+ });
250
+
251
+ if (!authResult.success) {
252
+ if (options.json) {
253
+ console.log(JSON.stringify({ error: authResult.error }, null, 2));
254
+ } else {
255
+ console.error(`Error: ${authResult.error}`);
256
+ console.error('\nCheck your .env file for EWS_CLIENT_ID and EWS_REFRESH_TOKEN.');
257
+ }
258
+ process.exit(1);
259
+ }
260
+
261
+ // Map folder names to API folder IDs
262
+ const folderMap: Record<string, string> = {
263
+ inbox: 'inbox',
264
+ sent: 'sentitems',
265
+ sentitems: 'sentitems',
266
+ drafts: 'drafts',
267
+ deleted: 'deleteditems',
268
+ deleteditems: 'deleteditems',
269
+ trash: 'deleteditems',
270
+ archive: 'archive',
271
+ junk: 'junkemail',
272
+ junkemail: 'junkemail',
273
+ spam: 'junkemail'
274
+ };
275
+
276
+ let apiFolder = folderMap[folder.toLowerCase()];
277
+
278
+ // If not a well-known folder, look up by name
279
+ if (!apiFolder) {
280
+ const foldersResult = await getMailFolders(authResult.token!, undefined, options.mailbox);
281
+ if (foldersResult.ok && foldersResult.data) {
282
+ const found = foldersResult.data.value.find((f) => f.DisplayName.toLowerCase() === folder.toLowerCase());
283
+ if (found) {
284
+ apiFolder = found.Id;
285
+ } else {
286
+ console.error(`Folder "${folder}" not found.`);
287
+ console.error('Use "m365-agent-cli folders" to see available folders.');
288
+ process.exit(1);
289
+ }
290
+ } else {
291
+ apiFolder = folder; // Fallback to using the name directly
292
+ }
293
+ }
294
+
295
+ const limit = parseInt(options.limit, 10) || 10;
296
+ const page = parseInt(options.page, 10) || 1;
297
+ const skip = (page - 1) * limit;
298
+
299
+ const result = await getEmails({
300
+ token: authResult.token!,
301
+ folder: apiFolder,
302
+ mailbox: options.mailbox,
303
+ top: limit,
304
+ skip,
305
+ search: options.search,
306
+ isRead: options.unread ? false : undefined,
307
+ flagStatus: options.flagged ? 'Flagged' : undefined
308
+ });
309
+
310
+ if (!result.ok || !result.data) {
311
+ if (options.json) {
312
+ console.log(JSON.stringify({ error: result.error?.message || 'Failed to fetch emails' }, null, 2));
313
+ } else {
314
+ console.error(`Error: ${result.error?.message || 'Failed to fetch emails'}`);
315
+ }
316
+ process.exit(1);
317
+ }
318
+
319
+ const emails = result.data.value;
320
+
321
+ // Handle reading a specific email
322
+ if (options.read) {
323
+ const id = options.read.trim();
324
+ const fullEmail = await getEmail(authResult.token!, id, options.mailbox);
325
+
326
+ if (!fullEmail.ok || !fullEmail.data) {
327
+ console.error(`Error: ${fullEmail.error?.message || 'Failed to fetch email'}`);
328
+ process.exit(1);
329
+ }
330
+
331
+ const email = fullEmail.data;
332
+
333
+ if (options.json) {
334
+ console.log(JSON.stringify(email, null, 2));
335
+ return;
336
+ }
337
+
338
+ console.log(`\n${'\u2500'.repeat(60)}`);
339
+ console.log(`From: ${email.From?.EmailAddress?.Name || email.From?.EmailAddress?.Address || 'Unknown'}`);
340
+ if (email.From?.EmailAddress?.Address) {
341
+ console.log(` <${email.From.EmailAddress.Address}>`);
342
+ }
343
+ console.log(`Subject: ${email.Subject || '(no subject)'}`);
344
+ console.log(`Date: ${email.ReceivedDateTime ? new Date(email.ReceivedDateTime).toLocaleString() : 'Unknown'}`);
345
+ if (email.Categories?.length) {
346
+ console.log(`Categories: ${email.Categories.join(', ')}`);
347
+ }
348
+
349
+ if (email.ToRecipients && email.ToRecipients.length > 0) {
350
+ const to = email.ToRecipients.map((r) => r.EmailAddress?.Address)
351
+ .filter(Boolean)
352
+ .join(', ');
353
+ console.log(`To: ${to}`);
354
+ }
355
+
356
+ if (email.CcRecipients && email.CcRecipients.length > 0) {
357
+ const cc = email.CcRecipients.map((r) => r.EmailAddress?.Address)
358
+ .filter(Boolean)
359
+ .join(', ');
360
+ console.log(`Cc: ${cc}`);
361
+ }
362
+
363
+ if (email.HasAttachments) {
364
+ const attachmentsResult = await getAttachments(authResult.token!, email.Id, options.mailbox);
365
+ if (attachmentsResult.ok && attachmentsResult.data) {
366
+ const atts = attachmentsResult.data.value.filter((a) => !a.IsInline);
367
+ if (atts.length > 0) {
368
+ console.log('Attachments:');
369
+ for (const att of atts) {
370
+ if (att.Kind === 'reference' && att.AttachLongPathName) {
371
+ console.log(` - ${att.Name} (link)`);
372
+ console.log(` ${att.AttachLongPathName}`);
373
+ } else {
374
+ const sizeKB = Math.round(att.Size / 1024);
375
+ console.log(` - ${att.Name} (${sizeKB} KB)`);
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ console.log(`${'\u2500'.repeat(60)}\n`);
383
+ console.log(email.Body?.Content || email.BodyPreview || '(no content)');
384
+ console.log(`\n${'\u2500'.repeat(60)}\n`);
385
+ return;
386
+ }
387
+
388
+ // Handle downloading attachments
389
+ if (options.download) {
390
+ const id = options.download.trim();
391
+ const emailSummary = await getEmail(authResult.token!, id, options.mailbox);
392
+ if (!emailSummary.ok || !emailSummary.data) {
393
+ console.error(`Error: ${emailSummary.error?.message || 'Failed to fetch email'}`);
394
+ process.exit(1);
395
+ }
396
+
397
+ if (!emailSummary.data.HasAttachments) {
398
+ console.log('This email has no attachments.');
399
+ return;
400
+ }
401
+
402
+ const attachmentsResult = await getAttachments(authResult.token!, emailSummary.data.Id, options.mailbox);
403
+ if (!attachmentsResult.ok || !attachmentsResult.data) {
404
+ console.error(`Error: ${attachmentsResult.error?.message || 'Failed to fetch attachments'}`);
405
+ process.exit(1);
406
+ }
407
+
408
+ const attachments = attachmentsResult.data.value.filter((a) => !a.IsInline);
409
+
410
+ if (attachments.length === 0) {
411
+ console.log('This email has no downloadable attachments.');
412
+ return;
413
+ }
414
+
415
+ // Ensure output directory exists
416
+ await mkdir(options.output, { recursive: true });
417
+
418
+ console.log(`\nDownloading ${attachments.length} attachment(s) to ${options.output}/\n`);
419
+
420
+ const usedPaths = new Set<string>();
421
+
422
+ for (const att of attachments) {
423
+ if (att.Kind === 'reference' || att.AttachLongPathName) {
424
+ let url = att.AttachLongPathName;
425
+ if (!url) {
426
+ const fullRef = await getAttachment(authResult.token!, emailSummary.data.Id, att.Id, options.mailbox);
427
+ if (fullRef.ok && fullRef.data?.AttachLongPathName) {
428
+ url = fullRef.data.AttachLongPathName;
429
+ }
430
+ }
431
+ if (!url) {
432
+ console.error(` Failed to resolve link: ${att.Name}`);
433
+ continue;
434
+ }
435
+ const safeBase = (att.Name || 'link').replace(/[/\\?%*:|"<>]/g, '_').trim() || 'link';
436
+ let filePath = join(options.output, `${safeBase}.url`);
437
+ let counter = 1;
438
+ while (usedPaths.has(filePath)) {
439
+ filePath = join(options.output, `${safeBase} (${counter}).url`);
440
+ counter++;
441
+ }
442
+ if (!options.force) {
443
+ while (true) {
444
+ try {
445
+ await access(filePath);
446
+ filePath = join(options.output, `${safeBase} (${counter}).url`);
447
+ counter++;
448
+ } catch {
449
+ break;
450
+ }
451
+ }
452
+ }
453
+ usedPaths.add(filePath);
454
+ const shortcut = `[InternetShortcut]\r\nURL=${url}\r\n`;
455
+ await writeFile(filePath, shortcut, 'utf8');
456
+ console.log(` \u2713 ${filePath.split(/[\\/]/).pop()} (link)`);
457
+ continue;
458
+ }
459
+
460
+ const fullAtt = await getAttachment(authResult.token!, emailSummary.data.Id, att.Id, options.mailbox);
461
+ if (!fullAtt.ok || !fullAtt.data?.ContentBytes) {
462
+ console.error(` Failed to download: ${att.Name}`);
463
+ continue;
464
+ }
465
+
466
+ const content = Buffer.from(fullAtt.data.ContentBytes, 'base64');
467
+
468
+ // Resolve the actual file path, avoiding collisions and existing files
469
+ let filePath = join(options.output, att.Name);
470
+ let counter = 1;
471
+ while (true) {
472
+ // Always check for intra-download collisions
473
+ if (usedPaths.has(filePath)) {
474
+ const ext = extname(att.Name);
475
+ const base = att.Name.slice(0, att.Name.length - ext.length);
476
+ filePath = join(options.output, `${base} (${counter})${ext}`);
477
+ counter++;
478
+ continue;
479
+ }
480
+
481
+ // Check for pre-existing files only if --force is not set
482
+ if (!options.force) {
483
+ try {
484
+ await access(filePath);
485
+ // File exists — resolve collision with a numeric suffix
486
+ const ext = extname(att.Name);
487
+ const base = att.Name.slice(0, att.Name.length - ext.length);
488
+ filePath = join(options.output, `${base} (${counter})${ext}`);
489
+ counter++;
490
+ continue;
491
+ } catch {
492
+ // File doesn't exist — safe to use
493
+ }
494
+ }
495
+
496
+ // Path is safe to use
497
+ break;
498
+ }
499
+
500
+ usedPaths.add(filePath);
501
+ await writeFile(filePath, content);
502
+
503
+ const sizeKB = Math.round(content.length / 1024);
504
+ const written = filePath === join(options.output, att.Name) ? att.Name : filePath.split(/[\\/]/).pop();
505
+ console.log(` \u2713 ${written} (${sizeKB} KB)`);
506
+ }
507
+
508
+ console.log('\nDone.\n');
509
+ return;
510
+ }
511
+
512
+ // Handle mark as read/unread
513
+ if (options.markRead || options.markUnread) {
514
+ const id = (options.markRead || options.markUnread)?.trim();
515
+ if (!id) {
516
+ console.error('Error: --mark-read/--mark-unread requires a message ID');
517
+ process.exit(1);
518
+ }
519
+ const isRead = !!options.markRead;
520
+
521
+ const result = await updateEmail(authResult.token!, id, { IsRead: isRead }, options.mailbox);
522
+
523
+ if (!result.ok) {
524
+ console.error(`Error: ${result.error?.message || 'Failed to update email'}`);
525
+ process.exit(1);
526
+ }
527
+
528
+ console.log(`\u2713 Marked as ${isRead ? 'read' : 'unread'}: ${id}`);
529
+ return;
530
+ }
531
+
532
+ // Handle Outlook categories (names; colors come from mailbox master list in Outlook)
533
+ if (options.setCategories || options.clearCategories) {
534
+ const id = (options.setCategories || options.clearCategories)?.trim();
535
+ if (!id) {
536
+ console.error('Error: --set-categories/--clear-categories requires a message ID');
537
+ process.exit(1);
538
+ }
539
+ if (options.setCategories && options.clearCategories) {
540
+ console.error('Error: use either --set-categories or --clear-categories, not both');
541
+ process.exit(1);
542
+ }
543
+ if (options.setCategories) {
544
+ const cats = (options.category ?? []).map((c) => c.trim()).filter(Boolean);
545
+ if (cats.length === 0) {
546
+ console.error('Error: --set-categories requires at least one --category <name>');
547
+ process.exit(1);
548
+ }
549
+ const result = await updateEmail(authResult.token!, id, { categories: cats }, options.mailbox);
550
+ if (!result.ok) {
551
+ console.error(`Error: ${result.error?.message || 'Failed to set categories'}`);
552
+ process.exit(1);
553
+ }
554
+ console.log(`\u2713 Categories set (${cats.join(', ')}): ${id}`);
555
+ } else {
556
+ const result = await updateEmail(authResult.token!, id, { clearCategories: true }, options.mailbox);
557
+ if (!result.ok) {
558
+ console.error(`Error: ${result.error?.message || 'Failed to clear categories'}`);
559
+ process.exit(1);
560
+ }
561
+ console.log(`\u2713 Categories cleared: ${id}`);
562
+ }
563
+ return;
564
+ }
565
+
566
+ // Handle flag/unflag/complete
567
+ if (options.flag || options.unflag || options.complete) {
568
+ const id = (options.flag || options.unflag || options.complete)?.trim();
569
+ let flagStatus: 'NotFlagged' | 'Flagged' | 'Complete';
570
+ let actionLabel: string;
571
+ let startDate: { DateTime: string; TimeZone: string } | undefined;
572
+ let dueDate: { DateTime: string; TimeZone: string } | undefined;
573
+
574
+ if (options.flag) {
575
+ flagStatus = 'Flagged';
576
+ actionLabel = 'Flagged';
577
+
578
+ if (options.startDate) {
579
+ let parsedStartDate: Date;
580
+ try {
581
+ parsedStartDate = parseDay(options.startDate, { throwOnInvalid: true });
582
+ } catch (err) {
583
+ console.error(`Error: Invalid start date: ${err instanceof Error ? err.message : String(err)}`);
584
+ process.exit(1);
585
+ }
586
+ startDate = { DateTime: toLocalUnzonedISOString(parsedStartDate), TimeZone: 'UTC' };
587
+ }
588
+ if (options.due) {
589
+ let parsedDueDate: Date;
590
+ try {
591
+ parsedDueDate = parseDay(options.due, { throwOnInvalid: true });
592
+ } catch (err) {
593
+ console.error(`Error: Invalid due date: ${err instanceof Error ? err.message : String(err)}`);
594
+ process.exit(1);
595
+ }
596
+ dueDate = { DateTime: toLocalUnzonedISOString(parsedDueDate), TimeZone: 'UTC' };
597
+ }
598
+ } else if (options.complete) {
599
+ flagStatus = 'Complete';
600
+ actionLabel = 'Marked complete';
601
+ } else {
602
+ flagStatus = 'NotFlagged';
603
+ actionLabel = 'Unflagged';
604
+ }
605
+
606
+ if (!id) {
607
+ console.error('Error: --flag/--unflag/--complete requires a message ID');
608
+ process.exit(1);
609
+ }
610
+ const result = await updateEmail(
611
+ authResult.token!,
612
+ id,
613
+ {
614
+ Flag: { FlagStatus: flagStatus, StartDate: startDate, DueDate: dueDate }
615
+ },
616
+ options.mailbox
617
+ );
618
+
619
+ if (!result.ok) {
620
+ console.error(`Error: ${result.error?.message || 'Failed to update email'}`);
621
+ process.exit(1);
622
+ }
623
+
624
+ console.log(`\u2713 ${actionLabel}: ${id}`);
625
+ return;
626
+ }
627
+
628
+ // Handle sensitivity
629
+ if (options.sensitivity) {
630
+ const id = options.sensitivity.trim();
631
+
632
+ if (!options.level) {
633
+ console.error('Error: --sensitivity requires --level to be specified');
634
+ console.error('Example: m365-agent-cli mail --sensitivity <id> --level personal');
635
+ console.error('Levels: normal, personal, private, confidential');
636
+ process.exit(1);
637
+ }
638
+
639
+ const sensitivity = SENSITIVITY_MAP[options.level.toLowerCase()];
640
+
641
+ if (!sensitivity) {
642
+ console.error(`Invalid sensitivity level: ${options.level}`);
643
+ console.error('Valid levels: normal, personal, private, confidential');
644
+ process.exit(1);
645
+ }
646
+
647
+ const result = await updateEmail(authResult.token!, id, { Sensitivity: sensitivity }, options.mailbox);
648
+
649
+ if (!result.ok) {
650
+ console.error(`Error: ${result.error?.message || 'Failed to update email sensitivity'}`);
651
+ process.exit(1);
652
+ }
653
+
654
+ console.log(`\u2713 Sensitivity set to ${sensitivity}: ${id}`);
655
+ return;
656
+ }
657
+
658
+ // Handle move
659
+ if (options.move) {
660
+ if (!options.to) {
661
+ console.error('Please specify destination folder with --to');
662
+ console.error('Example: m365-agent-cli mail --move <id> --to archive');
663
+ console.error('Folders: inbox, archive, deleted, junk, drafts, sent');
664
+ process.exit(1);
665
+ }
666
+
667
+ const id = options.move.trim();
668
+
669
+ // Map folder names to API folder IDs
670
+ const destFolderMap: Record<string, string> = {
671
+ inbox: 'inbox',
672
+ archive: 'archive',
673
+ deleted: 'deleteditems',
674
+ deleteditems: 'deleteditems',
675
+ trash: 'deleteditems',
676
+ junk: 'junkemail',
677
+ junkemail: 'junkemail',
678
+ spam: 'junkemail',
679
+ drafts: 'drafts',
680
+ sent: 'sentitems',
681
+ sentitems: 'sentitems'
682
+ };
683
+
684
+ let destFolder = destFolderMap[options.to.toLowerCase()];
685
+
686
+ // If not a well-known folder, look up by name
687
+ if (!destFolder) {
688
+ const foldersResult = await getMailFolders(authResult.token!, undefined, options.mailbox);
689
+ if (foldersResult.ok && foldersResult.data) {
690
+ const found = foldersResult.data.value.find(
691
+ (f) => f.DisplayName.toLowerCase() === options.to?.toLowerCase()
692
+ );
693
+ if (found) {
694
+ destFolder = found.Id;
695
+ } else {
696
+ console.error(`Folder "${options.to}" not found.`);
697
+ console.error('Use "m365-agent-cli folders" to see available folders.');
698
+ process.exit(1);
699
+ }
700
+ } else {
701
+ console.error('Failed to look up folder.');
702
+ process.exit(1);
703
+ }
704
+ }
705
+
706
+ const result = await moveEmail(authResult.token!, id, destFolder, options.mailbox);
707
+
708
+ if (!result.ok) {
709
+ console.error(`Error: ${result.error?.message || 'Failed to move email'}`);
710
+ process.exit(1);
711
+ }
712
+
713
+ const folderDisplay = options.to.charAt(0).toUpperCase() + options.to.slice(1);
714
+ console.log(`\u2713 Moved to ${folderDisplay}: ${id}`);
715
+ return;
716
+ }
717
+
718
+ // Handle reply
719
+ if (options.reply || options.replyAll) {
720
+ const id = (options.reply || options.replyAll)?.trim();
721
+
722
+ if (!id) {
723
+ console.error('Error: --reply/--reply-all requires a message ID');
724
+ process.exit(1);
725
+ }
726
+
727
+ if (!options.message) {
728
+ console.error('Please provide reply text with --message');
729
+ console.error('Example: m365-agent-cli mail --reply <id> --message "Thanks for your email!"');
730
+ process.exit(1);
731
+ }
732
+
733
+ const isReplyAll = !!options.replyAll;
734
+
735
+ let message = options.message;
736
+ let isHtml = false;
737
+
738
+ if (options.markdown) {
739
+ message = markdownToHtml(options.message);
740
+ isHtml = true;
741
+ }
742
+
743
+ const withCat = options.withCategory ?? [];
744
+ const hasAttach = !!options.attach?.trim();
745
+ const hasLinks = (options.attachLink?.length ?? 0) > 0;
746
+ const hasOutgoingExtras = hasAttach || hasLinks || withCat.filter((c) => c.trim()).length > 0;
747
+
748
+ if (options.draft && !hasOutgoingExtras) {
749
+ const result = await replyToEmailDraft(authResult.token!, id, message, isReplyAll, isHtml, options.mailbox);
750
+
751
+ if (!result.ok || !result.data) {
752
+ console.error(`Error: ${result.error?.message || 'Failed to create reply draft'}`);
753
+ process.exit(1);
754
+ }
755
+
756
+ const replyType = isReplyAll ? 'Reply all' : 'Reply';
757
+ console.log(`\u2713 ${replyType} draft created: ${result.data.draftId}`);
758
+ return;
759
+ }
760
+
761
+ if (hasOutgoingExtras) {
762
+ const draftR = await replyToEmailDraft(authResult.token!, id, message, isReplyAll, isHtml, options.mailbox);
763
+ if (!draftR.ok || !draftR.data) {
764
+ console.error(`Error: ${draftR.error?.message || 'Failed to create reply draft'}`);
765
+ process.exit(1);
766
+ }
767
+ const draftId = draftR.data.draftId;
768
+ await applyDraftCategoriesAttachments(authResult.token!, draftId, options.mailbox, {
769
+ withCategory: withCat,
770
+ attach: options.attach,
771
+ attachLink: options.attachLink,
772
+ json: options.json
773
+ });
774
+ if (options.draft) {
775
+ const replyType = isReplyAll ? 'Reply all' : 'Reply';
776
+ console.log(`\u2713 ${replyType} draft created: ${draftId}`);
777
+ return;
778
+ }
779
+ const sendR = await sendDraftById(authResult.token!, draftId, options.mailbox);
780
+ if (!sendR.ok) {
781
+ console.error(`Error: ${sendR.error?.message || 'Failed to send reply'}`);
782
+ process.exit(1);
783
+ }
784
+ const replyType = isReplyAll ? 'Reply all' : 'Reply';
785
+ console.log(`\u2713 ${replyType} sent (with attachments/categories): ${id}`);
786
+ return;
787
+ }
788
+
789
+ const result = await replyToEmail(authResult.token!, id, message, isReplyAll, isHtml, options.mailbox);
790
+
791
+ if (!result.ok) {
792
+ console.error(`Error: ${result.error?.message || 'Failed to send reply'}`);
793
+ process.exit(1);
794
+ }
795
+
796
+ const replyType = isReplyAll ? 'Reply all' : 'Reply';
797
+ console.log(`\u2713 ${replyType} sent to: ${id}`);
798
+ return;
799
+ }
800
+
801
+ // Handle forward
802
+ if (options.forward) {
803
+ const id = options.forward.trim();
804
+
805
+ if (!options.toAddr) {
806
+ console.error('Please provide forward recipients with --to-addr');
807
+ console.error('Example: m365-agent-cli mail --forward <id> --to-addr "user@example.com"');
808
+ process.exit(1);
809
+ }
810
+
811
+ const recipients = options.toAddr
812
+ .split(',')
813
+ .map((e) => e.trim())
814
+ .filter(Boolean);
815
+
816
+ if (!id) {
817
+ console.error('Error: --forward requires a message ID');
818
+ process.exit(1);
819
+ }
820
+
821
+ const withCat = options.withCategory ?? [];
822
+ const hasAttach = !!options.attach?.trim();
823
+ const hasLinks = (options.attachLink?.length ?? 0) > 0;
824
+ const hasOutgoingExtras = hasAttach || hasLinks || withCat.filter((c) => c.trim()).length > 0;
825
+
826
+ if (options.draft && !hasOutgoingExtras) {
827
+ const result = await forwardEmailDraft(authResult.token!, id, recipients, options.message, options.mailbox);
828
+ if (!result.ok || !result.data) {
829
+ console.error(`Error: ${result.error?.message || 'Failed to create forward draft'}`);
830
+ process.exit(1);
831
+ }
832
+ console.log(`\u2713 Forward draft created: ${result.data.draftId}`);
833
+ return;
834
+ }
835
+
836
+ if (hasOutgoingExtras) {
837
+ const draftR = await forwardEmailDraft(authResult.token!, id, recipients, options.message, options.mailbox);
838
+ if (!draftR.ok || !draftR.data) {
839
+ console.error(`Error: ${draftR.error?.message || 'Failed to create forward draft'}`);
840
+ process.exit(1);
841
+ }
842
+ const draftId = draftR.data.draftId;
843
+ await applyDraftCategoriesAttachments(authResult.token!, draftId, options.mailbox, {
844
+ withCategory: withCat,
845
+ attach: options.attach,
846
+ attachLink: options.attachLink,
847
+ json: options.json
848
+ });
849
+ if (options.draft) {
850
+ console.log(`\u2713 Forward draft created: ${draftId}`);
851
+ return;
852
+ }
853
+ const sendR = await sendDraftById(authResult.token!, draftId, options.mailbox);
854
+ if (!sendR.ok) {
855
+ console.error(`Error: ${sendR.error?.message || 'Failed to send forward'}`);
856
+ process.exit(1);
857
+ }
858
+ console.log(`\u2713 Forwarded to ${recipients.join(', ')} (with attachments/categories): ${id}`);
859
+ return;
860
+ }
861
+
862
+ const result = await forwardEmail(authResult.token!, id, recipients, options.message, options.mailbox);
863
+
864
+ if (!result.ok) {
865
+ console.error(`Error: ${result.error?.message || 'Failed to forward email'}`);
866
+ process.exit(1);
867
+ }
868
+
869
+ console.log(`\u2713 Forwarded to ${recipients.join(', ')}: ${id}`);
870
+ return;
871
+ }
872
+
873
+ // List emails
874
+ if (options.json) {
875
+ console.log(
876
+ JSON.stringify(
877
+ {
878
+ folder: apiFolder,
879
+ page,
880
+ limit,
881
+ emails: emails.map((e, i) => ({
882
+ index: skip + i + 1,
883
+ id: e.Id,
884
+ from: e.From?.EmailAddress?.Address,
885
+ fromName: e.From?.EmailAddress?.Name,
886
+ subject: e.Subject,
887
+ preview: e.BodyPreview,
888
+ receivedAt: e.ReceivedDateTime,
889
+ isRead: e.IsRead,
890
+ hasAttachments: e.HasAttachments,
891
+ importance: e.Importance,
892
+ flagged: e.Flag?.FlagStatus === 'Flagged',
893
+ categories: e.Categories
894
+ }))
895
+ },
896
+ null,
897
+ 2
898
+ )
899
+ );
900
+ return;
901
+ }
902
+
903
+ const folderDisplay = folder.charAt(0).toUpperCase() + folder.slice(1);
904
+ const searchInfo = options.search ? ` - search: "${options.search}"` : '';
905
+ const pageInfo = page > 1 ? ` (page ${page})` : '';
906
+ const mbInfo = options.mailbox ? ` — ${options.mailbox}` : '';
907
+ console.log(`\n\ud83d\udcec ${folderDisplay}${mbInfo}${searchInfo}${pageInfo}:\n`);
908
+ console.log('\u2500'.repeat(70));
909
+
910
+ if (emails.length === 0) {
911
+ console.log('\n No emails found.\n');
912
+ return;
913
+ }
914
+
915
+ for (let i = 0; i < emails.length; i++) {
916
+ const email = emails[i];
917
+ const idx = skip + i + 1;
918
+ const unreadMark = email.IsRead ? ' ' : '\u2022';
919
+ const flagMark = email.Flag?.FlagStatus === 'Flagged' ? '\u2691' : ' ';
920
+ const attachMark = email.HasAttachments ? '\ud83d\udcce' : ' ';
921
+ const importanceMark = email.Importance === 'High' ? '!' : ' ';
922
+
923
+ const from = email.From?.EmailAddress?.Name || email.From?.EmailAddress?.Address || 'Unknown';
924
+ const subject = email.Subject || '(no subject)';
925
+ const date = email.ReceivedDateTime ? formatDate(email.ReceivedDateTime) : '';
926
+
927
+ // Format: [idx] marks | from | subject | date
928
+ const marks = `${unreadMark}${flagMark}${attachMark}${importanceMark}`;
929
+ const fromTrunc = truncate(from, 20);
930
+ const subjectTrunc = truncate(subject, 35);
931
+
932
+ console.log(
933
+ ` [${idx.toString().padStart(2)}] ${marks} ${fromTrunc.padEnd(20)} ${subjectTrunc.padEnd(35)} ${date}`
934
+ );
935
+ console.log(` ID: ${email.Id}`);
936
+ if (email.Categories?.length) {
937
+ console.log(` Categories: ${email.Categories.join(', ')}`);
938
+ }
939
+ }
940
+
941
+ console.log(`\n${'\u2500'.repeat(70)}`);
942
+ console.log('\nCommands:');
943
+ console.log(` m365-agent-cli mail -r <id> # Read email`);
944
+ console.log(` m365-agent-cli mail -p ${page + 1} # Next page`);
945
+ console.log(` m365-agent-cli mail --unread # Only unread`);
946
+ console.log(` m365-agent-cli mail -s "keyword" # Search emails`);
947
+ console.log(` m365-agent-cli mail sent # Sent folder`);
948
+ console.log('');
949
+ }
950
+ );