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 +2 -0
- package/README.md +72 -4
- package/cli.js +36 -4
- package/email/attachments.js +359 -0
- package/email/index.js +58 -1
- package/package.json +1 -1
- package/utils/graph-api.js +122 -40
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
package/utils/graph-api.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
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
|
};
|