@littlebearapps/outlook-assistant 3.12.1 → 3.14.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.
- package/.env.example +27 -3
- package/README.md +108 -33
- package/advanced/index.js +44 -174
- package/auth/auth-errors.js +23 -1
- package/auth/client-config.js +142 -0
- package/auth/index.js +4 -2
- package/auth/oauth-server.js +12 -2
- package/auth/token-manager.js +7 -3
- package/auth/token-storage.js +46 -33
- package/auth/tools.js +223 -93
- package/calendar/attendees.js +36 -0
- package/calendar/cancel.js +9 -25
- package/calendar/create.js +42 -48
- package/calendar/decline.js +10 -25
- package/calendar/delete.js +10 -25
- package/calendar/index.js +20 -37
- package/calendar/list.js +4 -16
- package/calendar/preview.js +335 -0
- package/calendar/update.js +42 -86
- package/categories/index.js +59 -264
- package/config.js +36 -2
- package/contacts/index.js +72 -128
- package/email/attachments.js +42 -124
- package/email/conversations.js +44 -78
- package/email/delta.js +10 -34
- package/email/draft.js +140 -96
- package/email/export.js +141 -110
- package/email/folder-utils.js +3 -2
- package/email/headers.js +11 -49
- package/email/index.js +85 -109
- package/email/list.js +4 -17
- package/email/mail-tips.js +86 -57
- package/email/mark-as-read.js +13 -49
- package/email/mime.js +14 -49
- package/email/read.js +16 -50
- package/email/search.js +46 -86
- package/email/send.js +82 -48
- package/folder/create.js +6 -25
- package/folder/delete.js +117 -38
- package/folder/index.js +17 -16
- package/folder/list.js +5 -17
- package/folder/move.js +13 -42
- package/folder/resolve.js +11 -6
- package/folder/stats.js +6 -20
- package/index.js +23 -45
- package/llms-install.md +31 -7
- package/llms.txt +19 -10
- package/outlook-auth-server.js +10 -3
- package/package.json +6 -2
- package/request-handler.js +217 -116
- package/rules/create.js +27 -70
- package/rules/index.js +30 -92
- package/rules/list.js +5 -17
- package/rules/rule-builder.js +57 -20
- package/rules/update.js +26 -60
- package/server.js +37 -0
- package/settings/index.js +142 -143
- package/tools.js +30 -0
- package/utils/field-presets.js +4 -2
- package/utils/graph-api.js +65 -22
- package/utils/logger.js +251 -0
- package/utils/mock-data.js +91 -2
- package/utils/read-only.js +59 -0
- package/utils/response-formatter.js +54 -15
- package/utils/risk-classes.js +324 -0
- package/utils/safe-write.js +372 -6
- package/utils/safety.js +109 -25
- package/utils/server-instructions.js +62 -0
- package/utils/tool-error.js +33 -0
package/email/conversations.js
CHANGED
|
@@ -14,14 +14,22 @@ const { ensureAuthenticated } = require('../auth');
|
|
|
14
14
|
const { getEmailFields } = require('../utils/field-presets');
|
|
15
15
|
const { resolveFolderPath } = require('./folder-utils');
|
|
16
16
|
const { buildMailboxPrefix } = require('../utils/mailbox');
|
|
17
|
-
const {
|
|
17
|
+
const {
|
|
18
|
+
writeClaimedFile,
|
|
19
|
+
ensureOutputDir,
|
|
20
|
+
makeClaimedDir,
|
|
21
|
+
confineOutputPath,
|
|
22
|
+
OutputPathError,
|
|
23
|
+
} = require('../utils/safe-write');
|
|
18
24
|
const { escapeODataString } = require('../utils/odata-helpers');
|
|
19
25
|
const {
|
|
20
26
|
formatEmailContent,
|
|
21
27
|
formatEmailsAsCSV,
|
|
22
28
|
stripHtml,
|
|
23
29
|
VERBOSITY,
|
|
30
|
+
DEFAULT_LIMITS,
|
|
24
31
|
} = require('../utils/response-formatter');
|
|
32
|
+
const { toolError, authRequiredError } = require('../utils/tool-error');
|
|
25
33
|
// Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
|
|
26
34
|
// InefficientFilter on personal accounts with $orderby. Client-side filtering used instead.
|
|
27
35
|
|
|
@@ -235,20 +243,9 @@ async function handleListConversations(args) {
|
|
|
235
243
|
};
|
|
236
244
|
} catch (error) {
|
|
237
245
|
if (error.message === 'Authentication required') {
|
|
238
|
-
return
|
|
239
|
-
content: [
|
|
240
|
-
{
|
|
241
|
-
type: 'text',
|
|
242
|
-
text: "Authentication required. Please use the 'authenticate' tool first.",
|
|
243
|
-
},
|
|
244
|
-
],
|
|
245
|
-
};
|
|
246
|
+
return authRequiredError();
|
|
246
247
|
}
|
|
247
|
-
return {
|
|
248
|
-
content: [
|
|
249
|
-
{ type: 'text', text: `Error listing conversations: ${error.message}` },
|
|
250
|
-
],
|
|
251
|
-
};
|
|
248
|
+
return toolError(`Error listing conversations: ${error.message}`);
|
|
252
249
|
}
|
|
253
250
|
}
|
|
254
251
|
|
|
@@ -264,8 +261,8 @@ const EXPORT_CONVERSATION_MESSAGE_LIMIT = 1000;
|
|
|
264
261
|
* personal Microsoft accounts (400 InefficientFilter), so the query carries no
|
|
265
262
|
* `$orderby`: the pages are fetched and the messages are sorted here. Paging
|
|
266
263
|
* stops at the caller's `limit` or if Graph repeats a nextLink; either
|
|
267
|
-
* way the result is marked truncated. (
|
|
268
|
-
*
|
|
264
|
+
* way the result is marked truncated. (callGraphAPIPaginated stops the same
|
|
265
|
+
* way and reports `hasMore` since #279; this loop predates that.)
|
|
269
266
|
* @param {string} accessToken - Access token
|
|
270
267
|
* @param {string} prefix - Mailbox prefix (`me` or `users/{mailbox}`)
|
|
271
268
|
* @param {string} conversationId - Conversation ID
|
|
@@ -364,9 +361,7 @@ async function handleGetConversation(args) {
|
|
|
364
361
|
const prefix = buildMailboxPrefix(sharedMailbox);
|
|
365
362
|
|
|
366
363
|
if (!conversationId) {
|
|
367
|
-
return
|
|
368
|
-
content: [{ type: 'text', text: 'Conversation ID is required.' }],
|
|
369
|
-
};
|
|
364
|
+
return toolError('Conversation ID is required.');
|
|
370
365
|
}
|
|
371
366
|
|
|
372
367
|
try {
|
|
@@ -386,14 +381,9 @@ async function handleGetConversation(args) {
|
|
|
386
381
|
);
|
|
387
382
|
|
|
388
383
|
if (messages.length === 0) {
|
|
389
|
-
return
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
type: 'text',
|
|
393
|
-
text: `No messages found for conversation ID: ${conversationId}`,
|
|
394
|
-
},
|
|
395
|
-
],
|
|
396
|
-
};
|
|
384
|
+
return toolError(
|
|
385
|
+
`No messages found for conversation ID: ${conversationId}`
|
|
386
|
+
);
|
|
397
387
|
}
|
|
398
388
|
|
|
399
389
|
// Format output
|
|
@@ -407,7 +397,13 @@ async function handleGetConversation(args) {
|
|
|
407
397
|
|
|
408
398
|
messages.forEach((msg, index) => {
|
|
409
399
|
output.push(`## Message ${index + 1} of ${messages.length}`);
|
|
410
|
-
output.push(
|
|
400
|
+
output.push(
|
|
401
|
+
formatEmailContent(msg, verbosity, {
|
|
402
|
+
includeHeaders,
|
|
403
|
+
sharedMailbox,
|
|
404
|
+
maxFullBodyChars: DEFAULT_LIMITS.maxFullBodyChars,
|
|
405
|
+
})
|
|
406
|
+
);
|
|
411
407
|
output.push('\n---\n');
|
|
412
408
|
});
|
|
413
409
|
|
|
@@ -427,20 +423,9 @@ async function handleGetConversation(args) {
|
|
|
427
423
|
};
|
|
428
424
|
} catch (error) {
|
|
429
425
|
if (error.message === 'Authentication required') {
|
|
430
|
-
return
|
|
431
|
-
content: [
|
|
432
|
-
{
|
|
433
|
-
type: 'text',
|
|
434
|
-
text: "Authentication required. Please use the 'authenticate' tool first.",
|
|
435
|
-
},
|
|
436
|
-
],
|
|
437
|
-
};
|
|
426
|
+
return authRequiredError();
|
|
438
427
|
}
|
|
439
|
-
return {
|
|
440
|
-
content: [
|
|
441
|
-
{ type: 'text', text: `Error getting conversation: ${error.message}` },
|
|
442
|
-
],
|
|
443
|
-
};
|
|
428
|
+
return toolError(`Error getting conversation: ${error.message}`);
|
|
444
429
|
}
|
|
445
430
|
}
|
|
446
431
|
|
|
@@ -467,21 +452,22 @@ async function handleExportConversation(args) {
|
|
|
467
452
|
const prefix = buildMailboxPrefix(sharedMailbox);
|
|
468
453
|
|
|
469
454
|
if (!conversationId) {
|
|
470
|
-
return
|
|
471
|
-
content: [{ type: 'text', text: 'Conversation ID is required.' }],
|
|
472
|
-
};
|
|
455
|
+
return toolError('Conversation ID is required.');
|
|
473
456
|
}
|
|
474
457
|
|
|
475
458
|
const validFormats = ['eml', 'mbox', 'markdown', 'json', 'html', 'csv'];
|
|
476
459
|
if (!validFormats.includes(format)) {
|
|
477
|
-
return {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
460
|
+
return toolError(`Invalid format. Use: ${validFormats.join(', ')}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Resolve and check the directory before fetching anything; write only to
|
|
464
|
+
// the resolved path.
|
|
465
|
+
let resolvedDir;
|
|
466
|
+
try {
|
|
467
|
+
resolvedDir = confineOutputPath(outputDir);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (!(error instanceof OutputPathError)) throw error;
|
|
470
|
+
return toolError(error.message, { nextStep: error.nextStep });
|
|
485
471
|
}
|
|
486
472
|
|
|
487
473
|
try {
|
|
@@ -500,20 +486,14 @@ async function handleExportConversation(args) {
|
|
|
500
486
|
);
|
|
501
487
|
|
|
502
488
|
if (messages.length === 0) {
|
|
503
|
-
return
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
type: 'text',
|
|
507
|
-
text: `No messages found for conversation ID: ${conversationId}`,
|
|
508
|
-
},
|
|
509
|
-
],
|
|
510
|
-
};
|
|
489
|
+
return toolError(
|
|
490
|
+
`No messages found for conversation ID: ${conversationId}`
|
|
491
|
+
);
|
|
511
492
|
}
|
|
512
493
|
|
|
513
494
|
// Create output directory
|
|
514
|
-
const resolvedDir = path.resolve(outputDir);
|
|
515
495
|
if (!fs.existsSync(resolvedDir)) {
|
|
516
|
-
|
|
496
|
+
ensureOutputDir(resolvedDir);
|
|
517
497
|
}
|
|
518
498
|
|
|
519
499
|
// Generate filename base
|
|
@@ -788,23 +768,9 @@ async function handleExportConversation(args) {
|
|
|
788
768
|
};
|
|
789
769
|
} catch (error) {
|
|
790
770
|
if (error.message === 'Authentication required') {
|
|
791
|
-
return
|
|
792
|
-
content: [
|
|
793
|
-
{
|
|
794
|
-
type: 'text',
|
|
795
|
-
text: "Authentication required. Please use the 'authenticate' tool first.",
|
|
796
|
-
},
|
|
797
|
-
],
|
|
798
|
-
};
|
|
771
|
+
return authRequiredError();
|
|
799
772
|
}
|
|
800
|
-
return {
|
|
801
|
-
content: [
|
|
802
|
-
{
|
|
803
|
-
type: 'text',
|
|
804
|
-
text: `Error exporting conversation: ${error.message}`,
|
|
805
|
-
},
|
|
806
|
-
],
|
|
807
|
-
};
|
|
773
|
+
return toolError(`Error exporting conversation: ${error.message}`);
|
|
808
774
|
}
|
|
809
775
|
}
|
|
810
776
|
|
package/email/delta.js
CHANGED
|
@@ -10,6 +10,7 @@ const { formatEmailList, VERBOSITY } = require('../utils/response-formatter');
|
|
|
10
10
|
const { getEmailFields } = require('../utils/field-presets');
|
|
11
11
|
const { buildMailboxPrefix } = require('../utils/mailbox');
|
|
12
12
|
const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
|
|
13
|
+
const { toolError, authRequiredError } = require('../utils/tool-error');
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Extract the mailbox segment (`me` or `users/{address}`) from a delta/
|
|
@@ -116,16 +117,10 @@ async function handleListEmailsDelta(args) {
|
|
|
116
117
|
// different mailbox rather than silently syncing the wrong one.
|
|
117
118
|
const tokenMailbox = mailboxFromToken(deltaToken);
|
|
118
119
|
if (tokenMailbox && mailboxesConflict(tokenMailbox, prefix)) {
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
text:
|
|
124
|
-
`Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
|
|
125
|
-
'A delta token is bound to the mailbox and folder it was issued for. Use the token from that same mailbox/folder, or omit `deltaToken` to start a fresh initial sync here.',
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
};
|
|
120
|
+
return toolError(
|
|
121
|
+
`Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
|
|
122
|
+
'A delta token is bound to the mailbox and folder it was issued for. Use the token from that same mailbox/folder, or omit `deltaToken` to start a fresh initial sync here.'
|
|
123
|
+
);
|
|
129
124
|
}
|
|
130
125
|
endpoint = deltaToken;
|
|
131
126
|
} else {
|
|
@@ -295,14 +290,7 @@ async function handleListEmailsDelta(args) {
|
|
|
295
290
|
};
|
|
296
291
|
} catch (error) {
|
|
297
292
|
if (error.message === 'Authentication required') {
|
|
298
|
-
return
|
|
299
|
-
content: [
|
|
300
|
-
{
|
|
301
|
-
type: 'text',
|
|
302
|
-
text: "Authentication required. Please use the 'authenticate' tool first.",
|
|
303
|
-
},
|
|
304
|
-
],
|
|
305
|
-
};
|
|
293
|
+
return authRequiredError();
|
|
306
294
|
}
|
|
307
295
|
|
|
308
296
|
// Handle expired delta token
|
|
@@ -310,24 +298,12 @@ async function handleListEmailsDelta(args) {
|
|
|
310
298
|
error.message.includes('410') ||
|
|
311
299
|
error.message.includes('resyncRequired')
|
|
312
300
|
) {
|
|
313
|
-
return
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
type: 'text',
|
|
317
|
-
text: `## Delta Token Expired\n\nThe provided delta token has expired. Please start a new initial sync by calling without a deltaToken.\n\n**Error:** ${error.message}`,
|
|
318
|
-
},
|
|
319
|
-
],
|
|
320
|
-
};
|
|
301
|
+
return toolError(
|
|
302
|
+
`## Delta Token Expired\n\nThe provided delta token has expired. Please start a new initial sync by calling without a deltaToken.\n\n**Error:** ${error.message}`
|
|
303
|
+
);
|
|
321
304
|
}
|
|
322
305
|
|
|
323
|
-
return {
|
|
324
|
-
content: [
|
|
325
|
-
{
|
|
326
|
-
type: 'text',
|
|
327
|
-
text: `Delta sync failed: ${error.message}`,
|
|
328
|
-
},
|
|
329
|
-
],
|
|
330
|
-
};
|
|
306
|
+
return toolError(`Delta sync failed: ${error.message}`);
|
|
331
307
|
}
|
|
332
308
|
}
|
|
333
309
|
|
package/email/draft.js
CHANGED
|
@@ -9,9 +9,12 @@ const { ensureAuthenticated } = require('../auth');
|
|
|
9
9
|
const {
|
|
10
10
|
checkRateLimit,
|
|
11
11
|
checkRecipientAllowlist,
|
|
12
|
+
findBlockedRecipients,
|
|
13
|
+
getRecipientAllowlist,
|
|
12
14
|
formatDryRunPreview,
|
|
13
15
|
} = require('../utils/safety');
|
|
14
16
|
const { handleGetMailTips } = require('./mail-tips');
|
|
17
|
+
const { toolError, authRequiredError } = require('../utils/tool-error');
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Format comma-separated email string into Graph API recipient objects
|
|
@@ -25,6 +28,18 @@ function formatRecipients(recipientString) {
|
|
|
25
28
|
}));
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/** Graph fields holding a message's recipients. */
|
|
32
|
+
const RECIPIENT_FIELDS = ['toRecipients', 'ccRecipients', 'bccRecipients'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every to/cc/bcc recipient on a Graph message.
|
|
36
|
+
* @param {object} message - Graph message
|
|
37
|
+
* @returns {Array<{emailAddress: {address: string}}>}
|
|
38
|
+
*/
|
|
39
|
+
function recipientsOf(message) {
|
|
40
|
+
return RECIPIENT_FIELDS.flatMap((field) => message?.[field] || []);
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/**
|
|
29
44
|
* Auto-detect HTML vs plain text body
|
|
30
45
|
* @param {string} body - Email body content
|
|
@@ -108,9 +123,11 @@ class DraftGuardError extends Error {}
|
|
|
108
123
|
* @param {string} accessToken - Graph access token
|
|
109
124
|
* @param {string} id - Message id the caller passed as the draft id
|
|
110
125
|
* @param {string} action - The draft action being guarded (for the message)
|
|
126
|
+
* @param {string[]} [extraFields] - Further fields to fetch in the same GET
|
|
127
|
+
* @returns {Promise<object>} The draft, with id, isDraft, subject and extraFields
|
|
111
128
|
* @throws {DraftGuardError} If the id is not a draft or does not exist
|
|
112
129
|
*/
|
|
113
|
-
async function assertIsDraft(accessToken, id, action) {
|
|
130
|
+
async function assertIsDraft(accessToken, id, action, extraFields = []) {
|
|
114
131
|
let message;
|
|
115
132
|
try {
|
|
116
133
|
message = await callGraphAPI(
|
|
@@ -119,7 +136,7 @@ async function assertIsDraft(accessToken, id, action) {
|
|
|
119
136
|
`me/messages/${id}`,
|
|
120
137
|
null,
|
|
121
138
|
{
|
|
122
|
-
$select: 'id,isDraft,subject',
|
|
139
|
+
$select: ['id', 'isDraft', 'subject', ...extraFields].join(','),
|
|
123
140
|
}
|
|
124
141
|
);
|
|
125
142
|
} catch (error) {
|
|
@@ -137,6 +154,7 @@ async function assertIsDraft(accessToken, id, action) {
|
|
|
137
154
|
`Message \`${id}\`${subject} is not a draft, so draft action=${action} refused it and nothing was changed. update/send/delete only act on unsent drafts.`
|
|
138
155
|
);
|
|
139
156
|
}
|
|
157
|
+
return message;
|
|
140
158
|
}
|
|
141
159
|
|
|
142
160
|
/**
|
|
@@ -148,14 +166,9 @@ async function handleDraft(args) {
|
|
|
148
166
|
const { action } = args;
|
|
149
167
|
|
|
150
168
|
if (!action) {
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
type: 'text',
|
|
155
|
-
text: "Action is required. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.",
|
|
156
|
-
},
|
|
157
|
-
],
|
|
158
|
-
};
|
|
169
|
+
return toolError(
|
|
170
|
+
"Action is required. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'."
|
|
171
|
+
);
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
switch (action) {
|
|
@@ -174,14 +187,9 @@ async function handleDraft(args) {
|
|
|
174
187
|
case 'forward':
|
|
175
188
|
return handleForwardDraft(args);
|
|
176
189
|
default:
|
|
177
|
-
return
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
type: 'text',
|
|
181
|
-
text: `Invalid action '${action}'. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.`,
|
|
182
|
-
},
|
|
183
|
-
],
|
|
184
|
-
};
|
|
190
|
+
return toolError(
|
|
191
|
+
`Invalid action '${action}'. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.`
|
|
192
|
+
);
|
|
185
193
|
}
|
|
186
194
|
}
|
|
187
195
|
|
|
@@ -203,10 +211,12 @@ async function handleCreateDraft(args) {
|
|
|
203
211
|
if (allowlistError) return allowlistError;
|
|
204
212
|
}
|
|
205
213
|
|
|
206
|
-
// Pre-save recipient validation via mail-tips
|
|
214
|
+
// Pre-save recipient validation via mail-tips. The tips are returned
|
|
215
|
+
// with the result, never added to the draft payload (#272).
|
|
216
|
+
let tipsResult = null;
|
|
207
217
|
if (doCheckRecipients && allRecipients.length > 0) {
|
|
208
218
|
const allAddresses = allRecipients.map((r) => r.emailAddress.address);
|
|
209
|
-
|
|
219
|
+
tipsResult = await handleGetMailTips({ recipients: allAddresses });
|
|
210
220
|
const tipsText = tipsResult.content[0]?.text || '';
|
|
211
221
|
|
|
212
222
|
if (dryRun) {
|
|
@@ -254,7 +264,12 @@ async function handleCreateDraft(args) {
|
|
|
254
264
|
'me/messages',
|
|
255
265
|
message
|
|
256
266
|
);
|
|
257
|
-
|
|
267
|
+
const response = formatDraftResponse(draft, 'created');
|
|
268
|
+
if (tipsResult) {
|
|
269
|
+
response.content[0].text += `\n---\n\n${tipsResult.content[0]?.text || ''}`;
|
|
270
|
+
response._meta.mailTips = tipsResult._meta;
|
|
271
|
+
}
|
|
272
|
+
return response;
|
|
258
273
|
} catch (error) {
|
|
259
274
|
return handleError('creating draft', error);
|
|
260
275
|
}
|
|
@@ -267,11 +282,7 @@ async function handleUpdateDraft(args) {
|
|
|
267
282
|
const { id } = args;
|
|
268
283
|
|
|
269
284
|
if (!id) {
|
|
270
|
-
return
|
|
271
|
-
content: [
|
|
272
|
-
{ type: 'text', text: 'Draft ID (id) is required for update.' },
|
|
273
|
-
],
|
|
274
|
-
};
|
|
285
|
+
return toolError('Draft ID (id) is required for update.');
|
|
275
286
|
}
|
|
276
287
|
|
|
277
288
|
const message = buildMessageObject(args);
|
|
@@ -314,14 +325,29 @@ async function handleSendDraft(args) {
|
|
|
314
325
|
const { id } = args;
|
|
315
326
|
|
|
316
327
|
if (!id) {
|
|
317
|
-
return
|
|
318
|
-
content: [{ type: 'text', text: 'Draft ID (id) is required for send.' }],
|
|
319
|
-
};
|
|
328
|
+
return toolError('Draft ID (id) is required for send.');
|
|
320
329
|
}
|
|
321
330
|
|
|
322
331
|
try {
|
|
323
332
|
const accessToken = await ensureAuthenticated();
|
|
324
|
-
|
|
333
|
+
// Fetch the recipients as they are now: the draft may have been edited
|
|
334
|
+
// (here or in Outlook) since it was created.
|
|
335
|
+
const draft = await assertIsDraft(
|
|
336
|
+
accessToken,
|
|
337
|
+
id,
|
|
338
|
+
'send',
|
|
339
|
+
RECIPIENT_FIELDS
|
|
340
|
+
);
|
|
341
|
+
const blocked = findBlockedRecipients(recipientsOf(draft));
|
|
342
|
+
if (blocked) {
|
|
343
|
+
return toolError(
|
|
344
|
+
`Draft not sent: it is addressed to ${blocked.blocked.join(', ')}, which OUTLOOK_ALLOWED_RECIPIENTS does not allow (allowed recipients/domains: ${blocked.allowed.join(', ')}). The draft is unchanged and still in Drafts.`,
|
|
345
|
+
{
|
|
346
|
+
nextStep:
|
|
347
|
+
'Remove those recipients from the draft (in Outlook, or with draft action=update), or ask the user to add them to OUTLOOK_ALLOWED_RECIPIENTS.',
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
}
|
|
325
351
|
|
|
326
352
|
// Rate limit via send-email counter (shares limit with direct sends)
|
|
327
353
|
const rateLimitError = checkRateLimit('send-email');
|
|
@@ -348,11 +374,7 @@ async function handleDeleteDraft(args) {
|
|
|
348
374
|
const { id } = args;
|
|
349
375
|
|
|
350
376
|
if (!id) {
|
|
351
|
-
return
|
|
352
|
-
content: [
|
|
353
|
-
{ type: 'text', text: 'Draft ID (id) is required for delete.' },
|
|
354
|
-
],
|
|
355
|
-
};
|
|
377
|
+
return toolError('Draft ID (id) is required for delete.');
|
|
356
378
|
}
|
|
357
379
|
|
|
358
380
|
try {
|
|
@@ -379,25 +401,15 @@ async function handleReplyDraft(args, endpoint) {
|
|
|
379
401
|
const { id, body, comment } = args;
|
|
380
402
|
|
|
381
403
|
if (!id) {
|
|
382
|
-
return
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
type: 'text',
|
|
386
|
-
text: `Message ID (id) is required for ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'}.`,
|
|
387
|
-
},
|
|
388
|
-
],
|
|
389
|
-
};
|
|
404
|
+
return toolError(
|
|
405
|
+
`Message ID (id) is required for ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'}.`
|
|
406
|
+
);
|
|
390
407
|
}
|
|
391
408
|
|
|
392
409
|
if (comment && body) {
|
|
393
|
-
return
|
|
394
|
-
content
|
|
395
|
-
|
|
396
|
-
type: 'text',
|
|
397
|
-
text: 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.',
|
|
398
|
-
},
|
|
399
|
-
],
|
|
400
|
-
};
|
|
410
|
+
return toolError(
|
|
411
|
+
'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.'
|
|
412
|
+
);
|
|
401
413
|
}
|
|
402
414
|
|
|
403
415
|
const requestBody = {};
|
|
@@ -412,6 +424,13 @@ async function handleReplyDraft(args, endpoint) {
|
|
|
412
424
|
};
|
|
413
425
|
}
|
|
414
426
|
|
|
427
|
+
const actionName = endpoint === 'createReplyAll' ? 'reply-all' : 'reply';
|
|
428
|
+
|
|
429
|
+
// Counted before the draft is created: a reply that the allowlist refuses
|
|
430
|
+
// below has still written (and removed) a draft.
|
|
431
|
+
const rateLimitError = checkRateLimit('draft');
|
|
432
|
+
if (rateLimitError) return rateLimitError;
|
|
433
|
+
|
|
415
434
|
try {
|
|
416
435
|
const accessToken = await ensureAuthenticated();
|
|
417
436
|
const draft = await callGraphAPI(
|
|
@@ -420,17 +439,69 @@ async function handleReplyDraft(args, endpoint) {
|
|
|
420
439
|
`me/messages/${id}/${endpoint}`,
|
|
421
440
|
Object.keys(requestBody).length > 0 ? requestBody : null
|
|
422
441
|
);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
|
|
443
|
+
// Graph fills in the recipients from the original message, so they can
|
|
444
|
+
// only be checked once the draft exists.
|
|
445
|
+
const refusal = await refuseBlockedReply(accessToken, draft, actionName);
|
|
446
|
+
if (refusal) return refusal;
|
|
447
|
+
|
|
448
|
+
return formatDraftResponse(draft, `${actionName} draft created`);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return handleError(`creating ${actionName} draft`, error);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* When a recipient allowlist is configured, check the recipients Graph put
|
|
456
|
+
* on a new reply draft. If any is not allowed, delete the draft and return a
|
|
457
|
+
* refusal; otherwise return null.
|
|
458
|
+
* @param {string} accessToken - Graph access token
|
|
459
|
+
* @param {object} draft - The draft Graph returned from createReply/createReplyAll
|
|
460
|
+
* @param {string} actionName - 'reply' or 'reply-all'
|
|
461
|
+
* @returns {Promise<object|null>} A tool error, or null to keep the draft
|
|
462
|
+
*/
|
|
463
|
+
async function refuseBlockedReply(accessToken, draft, actionName) {
|
|
464
|
+
if (!getRecipientAllowlist()) return null;
|
|
465
|
+
|
|
466
|
+
let reason;
|
|
467
|
+
let nextStep;
|
|
468
|
+
try {
|
|
469
|
+
let message = draft;
|
|
470
|
+
if (!RECIPIENT_FIELDS.every((field) => Array.isArray(draft?.[field]))) {
|
|
471
|
+
message = await callGraphAPI(
|
|
472
|
+
accessToken,
|
|
473
|
+
'GET',
|
|
474
|
+
`me/messages/${draft.id}`,
|
|
475
|
+
null,
|
|
476
|
+
{ $select: ['id', ...RECIPIENT_FIELDS].join(',') }
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const blocked = findBlockedRecipients(recipientsOf(message));
|
|
480
|
+
if (!blocked) return null;
|
|
481
|
+
reason = `The ${actionName} draft would be addressed to ${blocked.blocked.join(', ')}, which OUTLOOK_ALLOWED_RECIPIENTS does not allow (allowed recipients/domains: ${blocked.allowed.join(', ')}).`;
|
|
482
|
+
nextStep =
|
|
483
|
+
'Reply only to allowed recipients (for example, action=reply rather than reply-all, or draft action=create addressed to them), or ask the user to add those recipients to OUTLOOK_ALLOWED_RECIPIENTS.';
|
|
428
484
|
} catch (error) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
485
|
+
// Unchecked recipients are treated like blocked ones.
|
|
486
|
+
reason = `The ${actionName} draft's recipients could not be checked against OUTLOOK_ALLOWED_RECIPIENTS (${error.message}).`;
|
|
487
|
+
nextStep = 'Try the call again.';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
await callGraphAPI(accessToken, 'DELETE', `me/messages/${draft.id}`);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
return toolError(
|
|
494
|
+
`${reason} The draft Graph created could not be deleted (${error.message}), so it is still in Drafts with ID \`${draft.id}\`. Do not send it.`,
|
|
495
|
+
{
|
|
496
|
+
nextStep: `Delete it with draft action=delete id=${draft.id} (or in Outlook). ${nextStep}`,
|
|
497
|
+
}
|
|
432
498
|
);
|
|
433
499
|
}
|
|
500
|
+
|
|
501
|
+
return toolError(
|
|
502
|
+
`${reason} The draft Graph created was deleted, so nothing was kept.`,
|
|
503
|
+
{ nextStep }
|
|
504
|
+
);
|
|
434
505
|
}
|
|
435
506
|
|
|
436
507
|
/**
|
|
@@ -440,33 +511,17 @@ async function handleForwardDraft(args) {
|
|
|
440
511
|
const { id, to, body, comment } = args;
|
|
441
512
|
|
|
442
513
|
if (!id) {
|
|
443
|
-
return
|
|
444
|
-
content: [
|
|
445
|
-
{ type: 'text', text: 'Message ID (id) is required for forward.' },
|
|
446
|
-
],
|
|
447
|
-
};
|
|
514
|
+
return toolError('Message ID (id) is required for forward.');
|
|
448
515
|
}
|
|
449
516
|
|
|
450
517
|
if (!to) {
|
|
451
|
-
return
|
|
452
|
-
content: [
|
|
453
|
-
{
|
|
454
|
-
type: 'text',
|
|
455
|
-
text: 'Forward recipient (to) is required for forward.',
|
|
456
|
-
},
|
|
457
|
-
],
|
|
458
|
-
};
|
|
518
|
+
return toolError('Forward recipient (to) is required for forward.');
|
|
459
519
|
}
|
|
460
520
|
|
|
461
521
|
if (comment && body) {
|
|
462
|
-
return
|
|
463
|
-
content
|
|
464
|
-
|
|
465
|
-
type: 'text',
|
|
466
|
-
text: 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.',
|
|
467
|
-
},
|
|
468
|
-
],
|
|
469
|
-
};
|
|
522
|
+
return toolError(
|
|
523
|
+
'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.'
|
|
524
|
+
);
|
|
470
525
|
}
|
|
471
526
|
|
|
472
527
|
const toRecipients = formatRecipients(to);
|
|
@@ -475,6 +530,9 @@ async function handleForwardDraft(args) {
|
|
|
475
530
|
const allowlistError = checkRecipientAllowlist(toRecipients);
|
|
476
531
|
if (allowlistError) return allowlistError;
|
|
477
532
|
|
|
533
|
+
const rateLimitError = checkRateLimit('draft');
|
|
534
|
+
if (rateLimitError) return rateLimitError;
|
|
535
|
+
|
|
478
536
|
const requestBody = {
|
|
479
537
|
toRecipients,
|
|
480
538
|
};
|
|
@@ -516,24 +574,10 @@ function handleError(actionLabel, error) {
|
|
|
516
574
|
}
|
|
517
575
|
|
|
518
576
|
if (error.message === 'Authentication required') {
|
|
519
|
-
return
|
|
520
|
-
content: [
|
|
521
|
-
{
|
|
522
|
-
type: 'text',
|
|
523
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
524
|
-
},
|
|
525
|
-
],
|
|
526
|
-
};
|
|
577
|
+
return authRequiredError();
|
|
527
578
|
}
|
|
528
579
|
|
|
529
|
-
return {
|
|
530
|
-
content: [
|
|
531
|
-
{
|
|
532
|
-
type: 'text',
|
|
533
|
-
text: `Error ${actionLabel}: ${error.message}`,
|
|
534
|
-
},
|
|
535
|
-
],
|
|
536
|
-
};
|
|
580
|
+
return toolError(`Error ${actionLabel}: ${error.message}`);
|
|
537
581
|
}
|
|
538
582
|
|
|
539
583
|
module.exports = handleDraft;
|