@littlebearapps/outlook-assistant 3.5.1 → 3.6.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/email/draft.js ADDED
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Draft email functionality
3
+ *
4
+ * Supports creating, updating, sending, and deleting drafts,
5
+ * plus creating reply/reply-all/forward drafts from existing messages.
6
+ */
7
+ const { callGraphAPI } = require('../utils/graph-api');
8
+ const { ensureAuthenticated } = require('../auth');
9
+ const {
10
+ checkRateLimit,
11
+ checkRecipientAllowlist,
12
+ formatDryRunPreview,
13
+ } = require('../utils/safety');
14
+ const { handleGetMailTips } = require('./mail-tips');
15
+
16
+ /**
17
+ * Format comma-separated email string into Graph API recipient objects
18
+ * @param {string} recipientString - Comma-separated email addresses
19
+ * @returns {Array<{emailAddress: {address: string}}>}
20
+ */
21
+ function formatRecipients(recipientString) {
22
+ if (!recipientString) return [];
23
+ return recipientString.split(',').map((email) => ({
24
+ emailAddress: { address: email.trim() },
25
+ }));
26
+ }
27
+
28
+ /**
29
+ * Auto-detect HTML vs plain text body
30
+ * @param {string} body - Email body content
31
+ * @returns {'html'|'text'}
32
+ */
33
+ function detectContentType(body) {
34
+ if (!body) return 'text';
35
+ return /<(html|div|p|h[1-6]|br|table|ul|ol|li|span|a\s|img|strong|em|b|i)\b/i.test(
36
+ body
37
+ )
38
+ ? 'html'
39
+ : 'text';
40
+ }
41
+
42
+ /**
43
+ * Build a message object from draft parameters
44
+ * @param {object} args - Draft parameters
45
+ * @returns {object} - Graph API message object
46
+ */
47
+ function buildMessageObject(args) {
48
+ const { to, cc, bcc, subject, body, importance } = args;
49
+ const message = {};
50
+
51
+ if (subject !== undefined) message.subject = subject;
52
+ if (body !== undefined) {
53
+ message.body = {
54
+ contentType: detectContentType(body),
55
+ content: body,
56
+ };
57
+ }
58
+ if (importance) message.importance = importance;
59
+
60
+ const toRecipients = formatRecipients(to);
61
+ const ccRecipients = formatRecipients(cc);
62
+ const bccRecipients = formatRecipients(bcc);
63
+
64
+ if (toRecipients.length > 0) message.toRecipients = toRecipients;
65
+ if (ccRecipients.length > 0) message.ccRecipients = ccRecipients;
66
+ if (bccRecipients.length > 0) message.bccRecipients = bccRecipients;
67
+
68
+ return message;
69
+ }
70
+
71
+ /**
72
+ * Format a draft response with key details
73
+ * @param {object} draft - Graph API message response
74
+ * @param {string} actionLabel - Human-readable action (e.g. "created", "updated")
75
+ * @returns {object} - MCP response
76
+ */
77
+ function formatDraftResponse(draft, actionLabel) {
78
+ const to = (draft.toRecipients || [])
79
+ .map((r) => r.emailAddress?.address)
80
+ .join(', ');
81
+
82
+ let text = `Draft ${actionLabel}.\n\n`;
83
+ text += `**ID**: \`${draft.id}\`\n`;
84
+ if (draft.subject) text += `**Subject**: ${draft.subject}\n`;
85
+ if (to) text += `**To**: ${to}\n`;
86
+ if (draft.lastModifiedDateTime)
87
+ text += `**Modified**: ${draft.lastModifiedDateTime}\n`;
88
+ if (draft.hasAttachments) text += `**Attachments**: yes\n`;
89
+
90
+ return {
91
+ content: [{ type: 'text', text }],
92
+ _meta: { draftId: draft.id },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Draft handler — routes to action-specific logic
98
+ * @param {object} args - Tool arguments
99
+ * @returns {object} - MCP response
100
+ */
101
+ async function handleDraft(args) {
102
+ const { action } = args;
103
+
104
+ if (!action) {
105
+ return {
106
+ content: [
107
+ {
108
+ type: 'text',
109
+ text: "Action is required. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.",
110
+ },
111
+ ],
112
+ };
113
+ }
114
+
115
+ switch (action) {
116
+ case 'create':
117
+ return handleCreateDraft(args);
118
+ case 'update':
119
+ return handleUpdateDraft(args);
120
+ case 'send':
121
+ return handleSendDraft(args);
122
+ case 'delete':
123
+ return handleDeleteDraft(args);
124
+ case 'reply':
125
+ return handleReplyDraft(args, 'createReply');
126
+ case 'reply-all':
127
+ return handleReplyDraft(args, 'createReplyAll');
128
+ case 'forward':
129
+ return handleForwardDraft(args);
130
+ default:
131
+ return {
132
+ content: [
133
+ {
134
+ type: 'text',
135
+ text: `Invalid action '${action}'. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.`,
136
+ },
137
+ ],
138
+ };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Create a new draft
144
+ */
145
+ async function handleCreateDraft(args) {
146
+ const { dryRun = false, checkRecipients: doCheckRecipients = false } = args;
147
+ const message = buildMessageObject(args);
148
+
149
+ // Check recipient allowlist if recipients specified
150
+ const allRecipients = [
151
+ ...(message.toRecipients || []),
152
+ ...(message.ccRecipients || []),
153
+ ...(message.bccRecipients || []),
154
+ ];
155
+ if (allRecipients.length > 0) {
156
+ const allowlistError = checkRecipientAllowlist(allRecipients);
157
+ if (allowlistError) return allowlistError;
158
+ }
159
+
160
+ // Pre-save recipient validation via mail-tips
161
+ if (doCheckRecipients && allRecipients.length > 0) {
162
+ const allAddresses = allRecipients.map((r) => r.emailAddress.address);
163
+ const tipsResult = await handleGetMailTips({ recipients: allAddresses });
164
+ const tipsText = tipsResult.content[0]?.text || '';
165
+
166
+ if (dryRun) {
167
+ const preview = formatDryRunPreview({ message, saveToSentItems: true });
168
+ return {
169
+ content: [
170
+ {
171
+ type: 'text',
172
+ text:
173
+ tipsText +
174
+ '\n\n---\n\n' +
175
+ preview.content[0].text.replace(
176
+ 'Email NOT sent',
177
+ 'Draft NOT saved'
178
+ ),
179
+ },
180
+ ],
181
+ _meta: { mailTips: tipsResult._meta },
182
+ };
183
+ }
184
+ }
185
+
186
+ // Dry-run mode: preview without saving
187
+ if (dryRun) {
188
+ const preview = formatDryRunPreview({ message, saveToSentItems: true });
189
+ return {
190
+ content: [
191
+ {
192
+ type: 'text',
193
+ text: preview.content[0].text.replace(
194
+ 'Email NOT sent',
195
+ 'Draft NOT saved'
196
+ ),
197
+ },
198
+ ],
199
+ };
200
+ }
201
+
202
+ // Rate limit check
203
+ const rateLimitError = checkRateLimit('draft');
204
+ if (rateLimitError) return rateLimitError;
205
+
206
+ try {
207
+ const accessToken = await ensureAuthenticated();
208
+ const draft = await callGraphAPI(
209
+ accessToken,
210
+ 'POST',
211
+ 'me/messages',
212
+ message
213
+ );
214
+ return formatDraftResponse(draft, 'created');
215
+ } catch (error) {
216
+ return handleError('creating draft', error);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Update an existing draft
222
+ */
223
+ async function handleUpdateDraft(args) {
224
+ const { id } = args;
225
+
226
+ if (!id) {
227
+ return {
228
+ content: [
229
+ { type: 'text', text: 'Draft ID (id) is required for update.' },
230
+ ],
231
+ };
232
+ }
233
+
234
+ const message = buildMessageObject(args);
235
+
236
+ // Check recipient allowlist if recipients changed
237
+ const allRecipients = [
238
+ ...(message.toRecipients || []),
239
+ ...(message.ccRecipients || []),
240
+ ...(message.bccRecipients || []),
241
+ ];
242
+ if (allRecipients.length > 0) {
243
+ const allowlistError = checkRecipientAllowlist(allRecipients);
244
+ if (allowlistError) return allowlistError;
245
+ }
246
+
247
+ // Rate limit check
248
+ const rateLimitError = checkRateLimit('draft');
249
+ if (rateLimitError) return rateLimitError;
250
+
251
+ try {
252
+ const accessToken = await ensureAuthenticated();
253
+ const draft = await callGraphAPI(
254
+ accessToken,
255
+ 'PATCH',
256
+ `me/messages/${id}`,
257
+ message
258
+ );
259
+ return formatDraftResponse(draft, 'updated');
260
+ } catch (error) {
261
+ return handleError('updating draft', error);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Send an existing draft
267
+ */
268
+ async function handleSendDraft(args) {
269
+ const { id } = args;
270
+
271
+ if (!id) {
272
+ return {
273
+ content: [{ type: 'text', text: 'Draft ID (id) is required for send.' }],
274
+ };
275
+ }
276
+
277
+ // Rate limit via send-email counter (shares limit with direct sends)
278
+ const rateLimitError = checkRateLimit('send-email');
279
+ if (rateLimitError) return rateLimitError;
280
+
281
+ try {
282
+ const accessToken = await ensureAuthenticated();
283
+ await callGraphAPI(accessToken, 'POST', `me/messages/${id}/send`);
284
+ return {
285
+ content: [
286
+ {
287
+ type: 'text',
288
+ text: `Draft sent successfully.\n\n**Note**: The draft ID \`${id}\` is no longer valid — the message has been moved to Sent Items with a new ID.`,
289
+ },
290
+ ],
291
+ };
292
+ } catch (error) {
293
+ return handleError('sending draft', error);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Delete a draft
299
+ */
300
+ async function handleDeleteDraft(args) {
301
+ const { id } = args;
302
+
303
+ if (!id) {
304
+ return {
305
+ content: [
306
+ { type: 'text', text: 'Draft ID (id) is required for delete.' },
307
+ ],
308
+ };
309
+ }
310
+
311
+ try {
312
+ const accessToken = await ensureAuthenticated();
313
+ await callGraphAPI(accessToken, 'DELETE', `me/messages/${id}`);
314
+ return {
315
+ content: [
316
+ {
317
+ type: 'text',
318
+ text: `Draft \`${id}\` deleted.`,
319
+ },
320
+ ],
321
+ };
322
+ } catch (error) {
323
+ return handleError('deleting draft', error);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Create a reply or reply-all draft from an existing message
329
+ */
330
+ async function handleReplyDraft(args, endpoint) {
331
+ const { id, body, comment } = args;
332
+
333
+ if (!id) {
334
+ return {
335
+ content: [
336
+ {
337
+ type: 'text',
338
+ text: `Message ID (id) is required for ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'}.`,
339
+ },
340
+ ],
341
+ };
342
+ }
343
+
344
+ if (comment && body) {
345
+ return {
346
+ content: [
347
+ {
348
+ type: 'text',
349
+ text: 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.',
350
+ },
351
+ ],
352
+ };
353
+ }
354
+
355
+ const requestBody = {};
356
+ if (comment) {
357
+ requestBody.comment = comment;
358
+ } else if (body) {
359
+ requestBody.message = {
360
+ body: {
361
+ contentType: detectContentType(body),
362
+ content: body,
363
+ },
364
+ };
365
+ }
366
+
367
+ try {
368
+ const accessToken = await ensureAuthenticated();
369
+ const draft = await callGraphAPI(
370
+ accessToken,
371
+ 'POST',
372
+ `me/messages/${id}/${endpoint}`,
373
+ Object.keys(requestBody).length > 0 ? requestBody : null
374
+ );
375
+ const label =
376
+ endpoint === 'createReplyAll'
377
+ ? 'reply-all draft created'
378
+ : 'reply draft created';
379
+ return formatDraftResponse(draft, label);
380
+ } catch (error) {
381
+ return handleError(
382
+ `creating ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'} draft`,
383
+ error
384
+ );
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Create a forward draft from an existing message
390
+ */
391
+ async function handleForwardDraft(args) {
392
+ const { id, to, body, comment } = args;
393
+
394
+ if (!id) {
395
+ return {
396
+ content: [
397
+ { type: 'text', text: 'Message ID (id) is required for forward.' },
398
+ ],
399
+ };
400
+ }
401
+
402
+ if (!to) {
403
+ return {
404
+ content: [
405
+ {
406
+ type: 'text',
407
+ text: 'Forward recipient (to) is required for forward.',
408
+ },
409
+ ],
410
+ };
411
+ }
412
+
413
+ if (comment && body) {
414
+ return {
415
+ content: [
416
+ {
417
+ type: 'text',
418
+ text: 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.',
419
+ },
420
+ ],
421
+ };
422
+ }
423
+
424
+ const toRecipients = formatRecipients(to);
425
+
426
+ // Check recipient allowlist
427
+ const allowlistError = checkRecipientAllowlist(toRecipients);
428
+ if (allowlistError) return allowlistError;
429
+
430
+ const requestBody = {
431
+ toRecipients,
432
+ };
433
+
434
+ if (comment) {
435
+ requestBody.comment = comment;
436
+ } else if (body) {
437
+ requestBody.message = {
438
+ body: {
439
+ contentType: detectContentType(body),
440
+ content: body,
441
+ },
442
+ };
443
+ }
444
+
445
+ try {
446
+ const accessToken = await ensureAuthenticated();
447
+ const draft = await callGraphAPI(
448
+ accessToken,
449
+ 'POST',
450
+ `me/messages/${id}/createForward`,
451
+ requestBody
452
+ );
453
+ return formatDraftResponse(draft, 'forward draft created');
454
+ } catch (error) {
455
+ return handleError('creating forward draft', error);
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Standard error handler
461
+ */
462
+ function handleError(actionLabel, error) {
463
+ if (error.message === 'Authentication required') {
464
+ return {
465
+ content: [
466
+ {
467
+ type: 'text',
468
+ text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
469
+ },
470
+ ],
471
+ };
472
+ }
473
+
474
+ return {
475
+ content: [
476
+ {
477
+ type: 'text',
478
+ text: `Error ${actionLabel}: ${error.message}`,
479
+ },
480
+ ],
481
+ };
482
+ }
483
+
484
+ module.exports = handleDraft;
package/email/index.js CHANGED
@@ -23,6 +23,7 @@ const {
23
23
  handleExportConversation,
24
24
  } = require('./conversations');
25
25
  const { handleGetMailTips } = require('./mail-tips');
26
+ const handleDraft = require('./draft');
26
27
 
27
28
  // Import flag handlers from advanced module
28
29
  const { handleSetMessageFlag, handleClearMessageFlag } = require('../advanced');
@@ -289,6 +290,84 @@ const emailTools = [
289
290
  },
290
291
  handler: handleSendEmail,
291
292
  },
293
+ {
294
+ name: 'draft',
295
+ description:
296
+ 'Manage email drafts. action=create saves a new draft. action=update edits an existing draft. action=send sends a draft. action=delete removes a draft. action=reply/reply-all/forward creates a reply or forward draft from an existing message.',
297
+ annotations: {
298
+ title: 'Draft Operations',
299
+ readOnlyHint: false,
300
+ destructiveHint: true,
301
+ idempotentHint: false,
302
+ openWorldHint: true,
303
+ },
304
+ inputSchema: {
305
+ type: 'object',
306
+ properties: {
307
+ action: {
308
+ type: 'string',
309
+ enum: [
310
+ 'create',
311
+ 'update',
312
+ 'send',
313
+ 'delete',
314
+ 'reply',
315
+ 'reply-all',
316
+ 'forward',
317
+ ],
318
+ description: 'Action to perform (required)',
319
+ },
320
+ id: {
321
+ type: 'string',
322
+ description:
323
+ 'Draft or message ID. Required for update/send/delete/reply/reply-all/forward.',
324
+ },
325
+ to: {
326
+ type: 'string',
327
+ description:
328
+ 'Comma-separated recipient email addresses (optional for create/update, required for forward)',
329
+ },
330
+ cc: {
331
+ type: 'string',
332
+ description: 'Comma-separated CC email addresses',
333
+ },
334
+ bcc: {
335
+ type: 'string',
336
+ description: 'Comma-separated BCC email addresses',
337
+ },
338
+ subject: {
339
+ type: 'string',
340
+ description: 'Email subject',
341
+ },
342
+ body: {
343
+ type: 'string',
344
+ description: 'Email body (plain text or HTML)',
345
+ },
346
+ importance: {
347
+ type: 'string',
348
+ enum: ['normal', 'high', 'low'],
349
+ description: 'Email importance (default: normal)',
350
+ },
351
+ comment: {
352
+ type: 'string',
353
+ description:
354
+ 'Comment text for reply/forward (prepended to original message). Cannot combine with body.',
355
+ },
356
+ dryRun: {
357
+ type: 'boolean',
358
+ description:
359
+ 'Preview draft without saving (action=create only, default: false)',
360
+ },
361
+ checkRecipients: {
362
+ type: 'boolean',
363
+ description:
364
+ 'Check recipients for out-of-office, delivery restrictions before saving (action=create, default: false)',
365
+ },
366
+ },
367
+ required: ['action'],
368
+ },
369
+ handler: handleDraft,
370
+ },
292
371
  {
293
372
  name: 'update-email',
294
373
  description:
@@ -559,6 +638,7 @@ const emailTools = [
559
638
 
560
639
  module.exports = {
561
640
  emailTools,
641
+ handleDraft,
562
642
  handleListEmails,
563
643
  handleSearchEmails,
564
644
  handleSearchByMessageId,
@@ -146,13 +146,46 @@ async function handleGetMailTips(args) {
146
146
  };
147
147
  }
148
148
 
149
+ // Normalise recipients to a clean array of email strings
150
+ let recipientList;
151
+ if (Array.isArray(recipients)) {
152
+ recipientList = recipients.map((e) => String(e).trim());
153
+ } else if (typeof recipients === 'string') {
154
+ // Handle JSON array strings like '["a@b.com","c@d.com"]' from MCP clients
155
+ const trimmed = recipients.trim();
156
+ if (trimmed.startsWith('[')) {
157
+ try {
158
+ recipientList = JSON.parse(trimmed).map((e) => String(e).trim());
159
+ } catch (_e) {
160
+ // Fall through to comma-split
161
+ }
162
+ }
163
+ if (!recipientList) {
164
+ recipientList = trimmed.split(',').map((e) => e.trim());
165
+ }
166
+ } else {
167
+ recipientList = [String(recipients).trim()];
168
+ }
169
+
170
+ // Filter out empty strings
171
+ recipientList = recipientList.filter((e) => e.length > 0);
172
+
173
+ if (recipientList.length === 0) {
174
+ return {
175
+ content: [
176
+ {
177
+ type: 'text',
178
+ text: 'At least one valid recipient email address is required.',
179
+ },
180
+ ],
181
+ };
182
+ }
183
+
149
184
  try {
150
185
  const accessToken = await ensureAuthenticated();
151
186
 
152
187
  const requestBody = {
153
- EmailAddresses: Array.isArray(recipients)
154
- ? recipients
155
- : recipients.split(',').map((e) => e.trim()),
188
+ EmailAddresses: recipientList,
156
189
  MailTipsOptions: tipTypes || MAIL_TIP_TYPES.join(','),
157
190
  };
158
191