outlook-cli 1.2.2 → 1.2.3

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/CLI.md CHANGED
@@ -56,6 +56,8 @@ outlook-cli call list-emails --args-json '{"folder":"inbox","count":5}' --ai
56
56
  outlook-cli email list --folder inbox --count 20
57
57
  outlook-cli email search --query "invoice" --unread-only true --count 10
58
58
  outlook-cli email read --id <message-id>
59
+ outlook-cli email attachments --id <message-id>
60
+ outlook-cli email attachment --id <message-id> --attachment-id <attachment-id> --save-path ./downloads/
59
61
  outlook-cli email send --to jane@example.com --subject "Status" --body "Done"
60
62
  outlook-cli email mark-read --id <message-id> --is-read true
61
63
  ```
package/README.md CHANGED
@@ -11,7 +11,7 @@ Production-ready CLI and MCP server for Microsoft Outlook through Microsoft Grap
11
11
  - Global CLI command: `outlook-cli`
12
12
  - One-time OAuth token storage — reused across runs and updates
13
13
  - Human-friendly commands with rich terminal output, theme presets, and machine-friendly `--json`/`--ai` mode
14
- - 19 MCP tools available to Claude and other AI assistants via the shared tool registry
14
+ - 21 MCP tools available to Claude and other AI assistants via the shared tool registry
15
15
  - Full email, calendar, folder, and rules management through Microsoft Graph API
16
16
 
17
17
  ---
@@ -181,6 +181,8 @@ outlook-cli auth server --status
181
181
  | `email list` | List recent emails | `outlook-cli email list --count 20` |
182
182
  | `email search` | Search emails by criteria | `outlook-cli email search --from boss@company.com` |
183
183
  | `email read` | Read full email content | `outlook-cli email read --id AAMkAGVm...` |
184
+ | `email attachments` | List attachments on one email | `outlook-cli email attachments --id AAMkAGVm...` |
185
+ | `email attachment` | Get one attachment and optionally save it | `outlook-cli email attachment --id AAMkAGVm... --attachment-id AA... --save-path ./downloads/` |
184
186
  | `email send` | Send a new email | `outlook-cli email send --to user@example.com --subject "Hi" --body "Hello"` |
185
187
  | `email mark-read` | Mark email as read or unread | `outlook-cli email mark-read --id AAMkAGVm...` |
186
188
 
@@ -284,6 +286,70 @@ outlook-cli email read --id AAMkAGVmMDAwAT... --json
284
286
 
285
287
  ---
286
288
 
289
+ #### `email attachments` — List Email Attachments
290
+
291
+ Lists attachments for a specific email message.
292
+
293
+ **Parameters:**
294
+
295
+ | Flag | Type | Required | Default | Description |
296
+ |---|---|---|---|---|
297
+ | `--id` | string | yes | — | Message ID from `email list` or `email search` |
298
+ | `--count` | number | no | `25` | Max attachments to list (1–50) |
299
+
300
+ **Examples:**
301
+ ```bash
302
+ # List attachments for one message
303
+ outlook-cli email attachments --id AAMkAGVmMDAwAT...
304
+
305
+ # Limit to first 10 attachments
306
+ outlook-cli email attachments --id AAMkAGVmMDAwAT... --count 10
307
+ ```
308
+
309
+ **Output shows:** Attachment ID, type (`fileAttachment`, `itemAttachment`, or `referenceAttachment`), content type, size, inline flag, and last modified date.
310
+
311
+ ---
312
+
313
+ #### `email attachment` — Get/Download One Attachment
314
+
315
+ Gets metadata for one attachment and optionally saves it to disk.
316
+
317
+ **Parameters:**
318
+
319
+ | Flag | Type | Required | Default | Description |
320
+ |---|---|---|---|---|
321
+ | `--id` | string | yes | — | Message ID |
322
+ | `--attachmentId` | string | yes | — | Attachment ID from `email attachments` |
323
+ | `--savePath` | string | no | — | File path or directory path to save downloaded bytes |
324
+ | `--includeContent` | boolean | no | `false` | Include text preview when attachment content is text-like |
325
+ | `--expandItem` | boolean | no | `false` | Expand metadata for item attachments |
326
+ | `--overwrite` | boolean | no | `false` | Overwrite existing file at destination path |
327
+
328
+ **Examples:**
329
+ ```bash
330
+ # Get metadata only
331
+ outlook-cli email attachment --id AAMkAGVmMDAwAT... --attachment-id AAMkAGVmMDAwAT...=
332
+
333
+ # Save attachment to downloads directory
334
+ outlook-cli email attachment \
335
+ --id AAMkAGVmMDAwAT... \
336
+ --attachment-id AAMkAGVmMDAwAT...= \
337
+ --save-path ./downloads/
338
+
339
+ # Save and include text preview when possible
340
+ outlook-cli email attachment \
341
+ --id AAMkAGVmMDAwAT... \
342
+ --attachment-id AAMkAGVmMDAwAT...= \
343
+ --save-path ./downloads/ \
344
+ --include-content
345
+ ```
346
+
347
+ **Notes:**
348
+ - `referenceAttachment` is a cloud link and cannot be downloaded as raw bytes from this endpoint.
349
+ - Item attachments use MIME raw content when downloaded.
350
+
351
+ ---
352
+
287
353
  #### `email send` — Send Email
288
354
 
289
355
  Composes and sends an email from the user's account.
@@ -326,7 +392,7 @@ outlook-cli email send \
326
392
 
327
393
  **Notes:**
328
394
  - HTML body is detected automatically when the body contains `<html`.
329
- - Attachments are not supported — body text only.
395
+ - Sending attachments is not supported yet — body text only.
330
396
 
331
397
  ---
332
398
 
@@ -836,7 +902,7 @@ outlook-cli calendar list --plain
836
902
 
837
903
  ## Complete MCP Tool Catalog
838
904
 
839
- All 19 tools are available through the shared MCP tool registry — used by Claude and other AI assistants, and also callable via `outlook-cli call <tool-name>`.
905
+ All 21 tools are available through the shared MCP tool registry — used by Claude and other AI assistants, and also callable via `outlook-cli call <tool-name>`.
840
906
 
841
907
  ### Authentication Tools
842
908
 
@@ -863,6 +929,8 @@ All 19 tools are available through the shared MCP tool registry — used by Clau
863
929
  | `list-emails` | List recent emails from a folder | none | `folder`, `count` |
864
930
  | `search-emails` | Search with text and filter criteria | none | `query`, `folder`, `from`, `to`, `subject`, `hasAttachments`, `unreadOnly`, `count` |
865
931
  | `read-email` | Read full content of one email | `id` | none |
932
+ | `list-attachments` | List attachments on a message | `messageId` | `count` |
933
+ | `get-attachment` | Get one attachment metadata and optionally download it | `messageId`, `attachmentId` | `savePath`, `includeContent`, `expandItem`, `overwrite` |
866
934
  | `send-email` | Send a new email | `to`, `subject`, `body` | `cc`, `bcc`, `importance`, `saveToSentItems` |
867
935
  | `mark-as-read` | Mark email read or unread | `id` | `isRead` (boolean, default `true`) |
868
936
 
@@ -999,7 +1067,7 @@ uv run python tools/install_skill.py --verify --personal
999
1067
  |---|---|
1000
1068
  | [skills/outlook-automation/SKILL.md](skills/outlook-automation/SKILL.md) | Main skill entry — overview, behavioral rules, quick examples, install instructions |
1001
1069
  | [skills/outlook-automation/reference/cli.md](skills/outlook-automation/reference/cli.md) | Complete CLI command reference — every command, flag, and example |
1002
- | [skills/outlook-automation/reference/json-schema.md](skills/outlook-automation/reference/json-schema.md) | JSON schemas for all 19 MCP tools |
1070
+ | [skills/outlook-automation/reference/json-schema.md](skills/outlook-automation/reference/json-schema.md) | JSON schemas for all 21 MCP tools |
1003
1071
  | [skills/outlook-automation/reference/security.md](skills/outlook-automation/reference/security.md) | OAuth flow, token lifecycle, permission scopes, AI safety rules |
1004
1072
  | [skills/outlook-automation/reference/troubleshooting.md](skills/outlook-automation/reference/troubleshooting.md) | Common errors, diagnostic checklist, step-by-step fixes |
1005
1073
 
package/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Production-oriented local CLI for outlook-cli.
4
4
  *
@@ -767,7 +767,7 @@ function printUsage() {
767
767
  formatRow('mcp-server', 'Run stdio MCP server (for Claude/Codex/VS Code)'),
768
768
  '',
769
769
  tone('Command groups', 'section'),
770
- formatRow('email', 'list|search|read|send|mark-read'),
770
+ formatRow('email', 'list|search|read|attachments|attachment|send|mark-read'),
771
771
  formatRow('calendar', 'list|create|decline|cancel|delete'),
772
772
  formatRow('folder', 'list|create|move'),
773
773
  formatRow('rule', 'list|create|sequence'),
@@ -775,6 +775,8 @@ function printUsage() {
775
775
  tone('Examples', 'section'),
776
776
  ` ${cliCommand('auth login --open --start-server --wait --timeout 180')}`,
777
777
  ` ${cliCommand('auth login --open --client-id <id> --client-secret <secret>')}`,
778
+ ` ${cliCommand('email attachments --id <message-id>')}`,
779
+ ` ${cliCommand('email attachment --id <message-id> --attachment-id <attachment-id> --save-path ./downloads/')}`,
778
780
  ` ${cliCommand('agents guide --json')}`,
779
781
  ` ${cliCommand("call list-emails --args-json '{\"folder\":\"inbox\",\"count\":5}' --ai")}`,
780
782
  ` ${cliCommand('tools schema send-email')}`,
@@ -867,7 +869,7 @@ function buildCommandCatalog() {
867
869
  return {
868
870
  commandGroups: {
869
871
  auth: ['status', 'url', 'login', 'logout', 'server'],
870
- email: ['list', 'search', 'read', 'send', 'mark-read'],
872
+ email: ['list', 'search', 'read', 'attachments', 'attachment', 'send', 'mark-read'],
871
873
  calendar: ['list', 'create', 'decline', 'cancel', 'delete'],
872
874
  folder: ['list', 'create', 'move'],
873
875
  rule: ['list', 'create', 'sequence'],
@@ -1401,6 +1403,36 @@ async function handleEmailCommand(action, options, outputMode) {
1401
1403
  return;
1402
1404
  }
1403
1405
 
1406
+ case 'attachments': {
1407
+ await callTool(
1408
+ 'list-attachments',
1409
+ {
1410
+ messageId: requireOption(options, 'id', `Usage: ${cliCommand('email attachments --id <email-id> [--count <number>]')}`),
1411
+ count: asNumber(readOption(options, 'count', 25), 25, 'count')
1412
+ },
1413
+ outputMode,
1414
+ 'email attachments'
1415
+ );
1416
+ return;
1417
+ }
1418
+
1419
+ case 'attachment': {
1420
+ await callTool(
1421
+ 'get-attachment',
1422
+ {
1423
+ messageId: requireOption(options, 'id', `Usage: ${cliCommand('email attachment --id <email-id> --attachment-id <attachment-id> [--save-path <path>]')}`),
1424
+ attachmentId: requireOption(options, 'attachmentId', `Usage: ${cliCommand('email attachment --id <email-id> --attachment-id <attachment-id> [--save-path <path>]')}`),
1425
+ savePath: readOption(options, 'savePath', readOption(options, 'out')),
1426
+ includeContent: asBoolean(readOption(options, 'includeContent', false), false),
1427
+ expandItem: asBoolean(readOption(options, 'expandItem', false), false),
1428
+ overwrite: asBoolean(readOption(options, 'overwrite', false), false)
1429
+ },
1430
+ outputMode,
1431
+ 'email attachment'
1432
+ );
1433
+ return;
1434
+ }
1435
+
1404
1436
  case 'send': {
1405
1437
  await callTool(
1406
1438
  'send-email',
@@ -1433,7 +1465,7 @@ async function handleEmailCommand(action, options, outputMode) {
1433
1465
  }
1434
1466
 
1435
1467
  default:
1436
- throw new UsageError('Unknown email command. Use: email list|search|read|send|mark-read');
1468
+ throw new UsageError('Unknown email command. Use: email list|search|read|attachments|attachment|send|mark-read');
1437
1469
  }
1438
1470
  }
1439
1471
 
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Email attachment listing and retrieval functionality
3
+ */
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { callGraphAPI, callGraphAPIRaw } = require('../utils/graph-api');
7
+ const { ensureAuthenticated } = require('../auth');
8
+
9
+ const DEFAULT_ATTACHMENT_COUNT = 25;
10
+ const MAX_ATTACHMENT_COUNT = 50;
11
+
12
+ function textResponse(text) {
13
+ return {
14
+ content: [{
15
+ type: 'text',
16
+ text
17
+ }]
18
+ };
19
+ }
20
+
21
+ function asBoolean(value, defaultValue = false) {
22
+ if (value === undefined || value === null || value === '') {
23
+ return defaultValue;
24
+ }
25
+
26
+ if (typeof value === 'boolean') {
27
+ return value;
28
+ }
29
+
30
+ if (typeof value === 'number') {
31
+ return value !== 0;
32
+ }
33
+
34
+ const normalized = String(value).trim().toLowerCase();
35
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
36
+ return true;
37
+ }
38
+
39
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
40
+ return false;
41
+ }
42
+
43
+ return defaultValue;
44
+ }
45
+
46
+ function asCount(value, defaultValue = DEFAULT_ATTACHMENT_COUNT) {
47
+ const parsed = Number(value);
48
+ if (!Number.isFinite(parsed) || parsed <= 0) {
49
+ return defaultValue;
50
+ }
51
+
52
+ return Math.min(parsed, MAX_ATTACHMENT_COUNT);
53
+ }
54
+
55
+ function getAttachmentKind(rawType) {
56
+ const type = String(rawType || '').toLowerCase();
57
+
58
+ if (type.includes('fileattachment')) {
59
+ return 'fileAttachment';
60
+ }
61
+
62
+ if (type.includes('itemattachment')) {
63
+ return 'itemAttachment';
64
+ }
65
+
66
+ if (type.includes('referenceattachment')) {
67
+ return 'referenceAttachment';
68
+ }
69
+
70
+ return 'attachment';
71
+ }
72
+
73
+ function sanitizeFileName(value) {
74
+ const raw = String(value || '').trim();
75
+ if (!raw) {
76
+ return 'attachment.bin';
77
+ }
78
+
79
+ const sanitized = raw
80
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
81
+ .replace(/\s+/g, ' ')
82
+ .trim();
83
+
84
+ return sanitized || 'attachment.bin';
85
+ }
86
+
87
+ function shouldTreatAsDirectory(inputPath) {
88
+ const value = String(inputPath || '');
89
+ return value.endsWith(path.sep) || value.endsWith('/') || value.endsWith('\\');
90
+ }
91
+
92
+ function resolveOutputFilePath(savePath, fallbackFileName) {
93
+ const requested = String(savePath || '').trim();
94
+ if (!requested) {
95
+ return '';
96
+ }
97
+
98
+ const resolved = path.resolve(requested);
99
+ if (fs.existsSync(resolved)) {
100
+ const stats = fs.statSync(resolved);
101
+ if (stats.isDirectory()) {
102
+ return path.join(resolved, fallbackFileName);
103
+ }
104
+
105
+ return resolved;
106
+ }
107
+
108
+ if (shouldTreatAsDirectory(requested)) {
109
+ return path.join(resolved, fallbackFileName);
110
+ }
111
+
112
+ return resolved;
113
+ }
114
+
115
+ function ensureOutputWritable(filePath, overwrite) {
116
+ if (fs.existsSync(filePath) && !overwrite) {
117
+ throw new Error(`File already exists at ${filePath}. Re-run with overwrite=true to replace it.`);
118
+ }
119
+
120
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
121
+ }
122
+
123
+ function looksLikeText(buffer) {
124
+ if (!buffer || buffer.length === 0) {
125
+ return false;
126
+ }
127
+
128
+ const sampleSize = Math.min(buffer.length, 2048);
129
+ let printableCount = 0;
130
+
131
+ for (let index = 0; index < sampleSize; index += 1) {
132
+ const byte = buffer[index];
133
+ const isPrintable = byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126);
134
+ if (isPrintable) {
135
+ printableCount += 1;
136
+ }
137
+ }
138
+
139
+ return (printableCount / sampleSize) >= 0.8;
140
+ }
141
+
142
+ function buildContentPreview(buffer) {
143
+ if (!looksLikeText(buffer)) {
144
+ return '';
145
+ }
146
+
147
+ return buffer
148
+ .toString('utf8', 0, Math.min(buffer.length, 4096))
149
+ .slice(0, 600)
150
+ .trim();
151
+ }
152
+
153
+ function defaultFileName(attachment, attachmentId) {
154
+ const kind = getAttachmentKind(attachment['@odata.type']);
155
+ const fromName = sanitizeFileName(attachment.name || '');
156
+
157
+ if (fromName !== 'attachment.bin') {
158
+ if (kind === 'itemAttachment' && !path.extname(fromName)) {
159
+ return `${fromName}.eml`;
160
+ }
161
+
162
+ return fromName;
163
+ }
164
+
165
+ if (kind === 'itemAttachment') {
166
+ return `attachment-${attachmentId}.eml`;
167
+ }
168
+
169
+ return `attachment-${attachmentId}.bin`;
170
+ }
171
+
172
+ async function getAttachmentBytes(accessToken, messageId, attachment, attachmentId) {
173
+ const kind = getAttachmentKind(attachment['@odata.type']);
174
+
175
+ if (kind === 'referenceAttachment') {
176
+ throw new Error('Reference attachments are cloud links and cannot be downloaded as raw bytes via this endpoint.');
177
+ }
178
+
179
+ if (kind === 'fileAttachment' && attachment.contentBytes) {
180
+ return Buffer.from(attachment.contentBytes, 'base64');
181
+ }
182
+
183
+ const rawResponse = await callGraphAPIRaw(
184
+ accessToken,
185
+ 'GET',
186
+ `me/messages/${messageId}/attachments/${attachmentId}/$value`
187
+ );
188
+
189
+ return rawResponse.body;
190
+ }
191
+
192
+ function formatAttachmentLine(attachment, index) {
193
+ const type = getAttachmentKind(attachment['@odata.type']);
194
+ const modified = attachment.lastModifiedDateTime
195
+ ? new Date(attachment.lastModifiedDateTime).toLocaleString()
196
+ : 'Unknown';
197
+
198
+ return `${index + 1}. ${attachment.name || '(unnamed attachment)'}
199
+ ID: ${attachment.id}
200
+ Type: ${type}
201
+ Content Type: ${attachment.contentType || 'unknown'}
202
+ Size: ${attachment.size || 0} bytes
203
+ Inline: ${attachment.isInline ? 'Yes' : 'No'}
204
+ Last Modified: ${modified}`;
205
+ }
206
+
207
+ /**
208
+ * List attachments for an email message
209
+ * @param {object} args - Tool arguments
210
+ * @returns {object} - MCP response
211
+ */
212
+ async function handleListAttachments(args = {}) {
213
+ const messageId = args.messageId || args.id;
214
+ const count = asCount(args.count, DEFAULT_ATTACHMENT_COUNT);
215
+
216
+ if (!messageId) {
217
+ return textResponse('Message ID is required.');
218
+ }
219
+
220
+ try {
221
+ const accessToken = await ensureAuthenticated();
222
+
223
+ const response = await callGraphAPI(
224
+ accessToken,
225
+ 'GET',
226
+ `me/messages/${messageId}/attachments`,
227
+ null,
228
+ {
229
+ $top: count,
230
+ $select: 'id,name,contentType,size,isInline,lastModifiedDateTime'
231
+ }
232
+ );
233
+
234
+ const attachments = Array.isArray(response.value) ? response.value : [];
235
+
236
+ if (attachments.length === 0) {
237
+ return textResponse(`No attachments found for message ${messageId}.`);
238
+ }
239
+
240
+ const formatted = attachments
241
+ .map((attachment, index) => formatAttachmentLine(attachment, index))
242
+ .join('\n\n');
243
+
244
+ return textResponse(`Found ${attachments.length} attachment(s) for message ${messageId}:\n\n${formatted}`);
245
+ } catch (error) {
246
+ if (error.message === 'Authentication required') {
247
+ return textResponse("Authentication required. Please use the 'authenticate' tool first.");
248
+ }
249
+
250
+ return textResponse(`Error listing attachments: ${error.message}`);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Get one attachment's details and optionally save it to disk
256
+ * @param {object} args - Tool arguments
257
+ * @returns {object} - MCP response
258
+ */
259
+ async function handleGetAttachment(args = {}) {
260
+ const messageId = args.messageId || args.id;
261
+ const attachmentId = args.attachmentId;
262
+ const savePath = args.savePath || args.outputPath || args.out;
263
+ const includeContent = asBoolean(args.includeContent, false);
264
+ const expandItem = asBoolean(args.expandItem, false);
265
+ const overwrite = asBoolean(args.overwrite, false);
266
+
267
+ if (!messageId) {
268
+ return textResponse('Message ID is required.');
269
+ }
270
+
271
+ if (!attachmentId) {
272
+ return textResponse('Attachment ID is required.');
273
+ }
274
+
275
+ try {
276
+ const accessToken = await ensureAuthenticated();
277
+ const queryParams = {};
278
+
279
+ if (expandItem) {
280
+ queryParams.$expand = 'microsoft.graph.itemattachment/item';
281
+ }
282
+
283
+ const attachment = await callGraphAPI(
284
+ accessToken,
285
+ 'GET',
286
+ `me/messages/${messageId}/attachments/${attachmentId}`,
287
+ null,
288
+ queryParams
289
+ );
290
+
291
+ if (!attachment || !attachment.id) {
292
+ return textResponse(`Attachment ${attachmentId} not found in message ${messageId}.`);
293
+ }
294
+
295
+ const kind = getAttachmentKind(attachment['@odata.type']);
296
+ const summary = [
297
+ `Attachment: ${attachment.name || '(unnamed attachment)'}`,
298
+ `ID: ${attachment.id}`,
299
+ `Type: ${kind}`,
300
+ `Content Type: ${attachment.contentType || 'unknown'}`,
301
+ `Size: ${attachment.size || 0} bytes`,
302
+ `Inline: ${attachment.isInline ? 'Yes' : 'No'}`
303
+ ];
304
+
305
+ if (kind === 'referenceAttachment') {
306
+ const sourceUrl = attachment.sourceUrl || attachment.providerType || 'Unknown';
307
+ summary.push(`Reference Target: ${sourceUrl}`);
308
+ }
309
+
310
+ if (attachment.item && typeof attachment.item === 'object') {
311
+ summary.push(`Attached Item Type: ${attachment.item['@odata.type'] || 'unknown'}`);
312
+ if (attachment.item.subject) {
313
+ summary.push(`Attached Item Subject: ${attachment.item.subject}`);
314
+ }
315
+ }
316
+
317
+ let downloadedBuffer = null;
318
+ if (savePath || includeContent) {
319
+ downloadedBuffer = await getAttachmentBytes(accessToken, messageId, attachment, attachmentId);
320
+ summary.push(`Downloaded Bytes: ${downloadedBuffer.length}`);
321
+ }
322
+
323
+ if (savePath) {
324
+ const destination = resolveOutputFilePath(
325
+ savePath,
326
+ defaultFileName(attachment, attachmentId)
327
+ );
328
+
329
+ ensureOutputWritable(destination, overwrite);
330
+ fs.writeFileSync(destination, downloadedBuffer);
331
+ summary.push(`Saved To: ${destination}`);
332
+ }
333
+
334
+ if (includeContent && downloadedBuffer) {
335
+ const preview = buildContentPreview(downloadedBuffer);
336
+ if (preview) {
337
+ summary.push('');
338
+ summary.push('Content Preview:');
339
+ summary.push(preview);
340
+ } else {
341
+ summary.push('');
342
+ summary.push('Content Preview: Binary content detected (no text preview).');
343
+ }
344
+ }
345
+
346
+ return textResponse(summary.join('\n'));
347
+ } catch (error) {
348
+ if (error.message === 'Authentication required') {
349
+ return textResponse("Authentication required. Please use the 'authenticate' tool first.");
350
+ }
351
+
352
+ return textResponse(`Error getting attachment: ${error.message}`);
353
+ }
354
+ }
355
+
356
+ module.exports = {
357
+ handleListAttachments,
358
+ handleGetAttachment
359
+ };
package/email/index.js CHANGED
@@ -6,6 +6,7 @@ const handleSearchEmails = require('./search');
6
6
  const handleReadEmail = require('./read');
7
7
  const handleSendEmail = require('./send');
8
8
  const handleMarkAsRead = require('./mark-as-read');
9
+ const { handleListAttachments, handleGetAttachment } = require('./attachments');
9
10
 
10
11
  // Email tool definitions
11
12
  const emailTools = [
@@ -144,6 +145,60 @@ const emailTools = [
144
145
  required: ["id"]
145
146
  },
146
147
  handler: handleMarkAsRead
148
+ },
149
+ {
150
+ name: "list-attachments",
151
+ description: "Lists attachments for a specific email",
152
+ inputSchema: {
153
+ type: "object",
154
+ properties: {
155
+ messageId: {
156
+ type: "string",
157
+ description: "ID of the message that contains attachments"
158
+ },
159
+ count: {
160
+ type: "number",
161
+ description: "Maximum number of attachments to list (default: 25, max: 50)"
162
+ }
163
+ },
164
+ required: ["messageId"]
165
+ },
166
+ handler: handleListAttachments
167
+ },
168
+ {
169
+ name: "get-attachment",
170
+ description: "Gets one attachment's metadata and optionally downloads it",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ messageId: {
175
+ type: "string",
176
+ description: "ID of the message that contains the attachment"
177
+ },
178
+ attachmentId: {
179
+ type: "string",
180
+ description: "ID of the attachment to read or download"
181
+ },
182
+ savePath: {
183
+ type: "string",
184
+ description: "Optional output file path or output directory path"
185
+ },
186
+ includeContent: {
187
+ type: "boolean",
188
+ description: "When true, include a text preview for text-like attachments"
189
+ },
190
+ expandItem: {
191
+ type: "boolean",
192
+ description: "When true, expand item attachment metadata"
193
+ },
194
+ overwrite: {
195
+ type: "boolean",
196
+ description: "When true, overwrite an existing file at savePath"
197
+ }
198
+ },
199
+ required: ["messageId", "attachmentId"]
200
+ },
201
+ handler: handleGetAttachment
147
202
  }
148
203
  ];
149
204
 
@@ -153,5 +208,7 @@ module.exports = {
153
208
  handleSearchEmails,
154
209
  handleReadEmail,
155
210
  handleSendEmail,
156
- handleMarkAsRead
211
+ handleMarkAsRead,
212
+ handleListAttachments,
213
+ handleGetAttachment
157
214
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outlook-cli",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Production-ready Outlook CLI with optional MCP server mode powered by Microsoft Graph API",
5
5
  "keywords": [
6
6
  "outlook-cli",
@@ -11,6 +11,39 @@ function debugLog(...args) {
11
11
  }
12
12
  }
13
13
 
14
+ function buildEncodedPath(path) {
15
+ return String(path || '')
16
+ .split('/')
17
+ .map((segment) => encodeURIComponent(segment))
18
+ .join('/');
19
+ }
20
+
21
+ function buildQueryString(queryParams = {}) {
22
+ if (!queryParams || Object.keys(queryParams).length === 0) {
23
+ return '';
24
+ }
25
+
26
+ const params = { ...queryParams };
27
+ const filter = params.$filter;
28
+ delete params.$filter;
29
+
30
+ let queryString = new URLSearchParams(params).toString();
31
+
32
+ if (filter) {
33
+ if (queryString) {
34
+ queryString += `&$filter=${encodeURIComponent(filter)}`;
35
+ } else {
36
+ queryString = `$filter=${encodeURIComponent(filter)}`;
37
+ }
38
+ }
39
+
40
+ if (!queryString) {
41
+ return '';
42
+ }
43
+
44
+ return `?${queryString}`;
45
+ }
46
+
14
47
  /**
15
48
  * Makes a request to the Microsoft Graph API
16
49
  * @param {string} accessToken - The access token for authentication
@@ -29,45 +62,12 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
29
62
 
30
63
  try {
31
64
  debugLog(`Making real API call: ${method} ${path}`);
32
-
33
- // Encode path segments properly
34
- const encodedPath = path.split('/')
35
- .map(segment => encodeURIComponent(segment))
36
- .join('/');
37
-
38
- // Build query string from parameters with special handling for OData filters
39
- let queryString = '';
40
- if (Object.keys(queryParams).length > 0) {
41
- // Handle $filter parameter specially to ensure proper URI encoding
42
- const filter = queryParams.$filter;
43
- if (filter) {
44
- delete queryParams.$filter; // Remove from regular params
45
- }
46
-
47
- // Build query string with proper encoding for regular params
48
- const params = new URLSearchParams();
49
- for (const [key, value] of Object.entries(queryParams)) {
50
- params.append(key, value);
51
- }
52
-
53
- queryString = params.toString();
54
-
55
- // Add filter parameter separately with proper encoding
56
- if (filter) {
57
- if (queryString) {
58
- queryString += `&$filter=${encodeURIComponent(filter)}`;
59
- } else {
60
- queryString = `$filter=${encodeURIComponent(filter)}`;
61
- }
62
- }
63
-
64
- if (queryString) {
65
- queryString = '?' + queryString;
66
- }
67
-
68
- debugLog(`Query string: ${queryString}`);
69
- }
70
-
65
+
66
+ const encodedPath = buildEncodedPath(path);
67
+ const queryString = buildQueryString(queryParams);
68
+
69
+ debugLog(`Query string: ${queryString}`);
70
+
71
71
  const url = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
72
72
  debugLog(`Full URL: ${url}`);
73
73
 
@@ -121,6 +121,88 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
121
121
  }
122
122
  }
123
123
 
124
+ /**
125
+ * Makes a raw request to the Microsoft Graph API and returns bytes.
126
+ * @param {string} accessToken - The access token for authentication
127
+ * @param {string} method - HTTP method (GET, POST, etc.)
128
+ * @param {string} path - API endpoint path
129
+ * @param {object} data - Data to send for POST/PUT requests
130
+ * @param {object} queryParams - Query parameters
131
+ * @returns {Promise<{statusCode:number, headers:object, body:Buffer}>}
132
+ */
133
+ async function callGraphAPIRaw(accessToken, method, path, data = null, queryParams = {}) {
134
+ if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
135
+ debugLog(`TEST MODE: Simulating raw ${method} ${path} API call`);
136
+ return {
137
+ statusCode: 200,
138
+ headers: {
139
+ 'content-type': 'application/octet-stream'
140
+ },
141
+ body: Buffer.from('')
142
+ };
143
+ }
144
+
145
+ try {
146
+ debugLog(`Making raw API call: ${method} ${path}`);
147
+
148
+ const encodedPath = buildEncodedPath(path);
149
+ const queryString = buildQueryString(queryParams);
150
+ const url = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
151
+ debugLog(`Full raw URL: ${url}`);
152
+
153
+ return new Promise((resolve, reject) => {
154
+ const options = {
155
+ method,
156
+ headers: {
157
+ Authorization: `Bearer ${accessToken}`
158
+ }
159
+ };
160
+
161
+ const req = https.request(url, options, (res) => {
162
+ const chunks = [];
163
+
164
+ res.on('data', (chunk) => {
165
+ chunks.push(chunk);
166
+ });
167
+
168
+ res.on('end', () => {
169
+ const body = Buffer.concat(chunks);
170
+
171
+ if (res.statusCode >= 200 && res.statusCode < 300) {
172
+ resolve({
173
+ statusCode: res.statusCode,
174
+ headers: res.headers,
175
+ body
176
+ });
177
+ return;
178
+ }
179
+
180
+ if (res.statusCode === 401) {
181
+ reject(new Error('UNAUTHORIZED'));
182
+ return;
183
+ }
184
+
185
+ reject(new Error(`API call failed with status ${res.statusCode}: ${body.toString('utf8')}`));
186
+ });
187
+ });
188
+
189
+ req.on('error', (error) => {
190
+ reject(new Error(`Network error during API call: ${error.message}`));
191
+ });
192
+
193
+ if (data && (method === 'POST' || method === 'PATCH' || method === 'PUT')) {
194
+ req.write(JSON.stringify(data));
195
+ }
196
+
197
+ req.end();
198
+ });
199
+ } catch (error) {
200
+ debugLog('Error calling Graph API (raw):', error);
201
+ throw error;
202
+ }
203
+ }
204
+
124
205
  module.exports = {
125
- callGraphAPI
206
+ callGraphAPI,
207
+ callGraphAPIRaw
126
208
  };