@littlebearapps/outlook-assistant 3.11.1 → 3.12.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/calendar/list.js CHANGED
@@ -4,6 +4,141 @@
4
4
  const config = require('../config');
5
5
  const { callGraphAPI } = require('../utils/graph-api');
6
6
  const { ensureAuthenticated } = require('../auth');
7
+ const {
8
+ escapeODataString,
9
+ buildODataFilter,
10
+ } = require('../utils/odata-helpers');
11
+
12
+ // An ISO 8601 instant with an explicit zone: `Z` or a ±hh:mm offset. A
13
+ // zone-less value would be read in the server's local timezone by Date.parse,
14
+ // so results would differ between machines; date-only values are rejected for
15
+ // the same reason.
16
+ const ISO_INSTANT =
17
+ /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
18
+ const MAX_SUBJECT_LENGTH = 255;
19
+
20
+ /**
21
+ * Error raised for invalid list-events arguments, so the handler can report it
22
+ * as a tool error without touching the network.
23
+ */
24
+ class ListEventsArgumentError extends Error {}
25
+
26
+ /**
27
+ * Parse an ISO 8601 instant (with `Z` or a ±hh:mm offset) and return it as a
28
+ * UTC ISO string. Events are requested in UTC, so comparing against a UTC
29
+ * instant keeps the filter correct for offset inputs such as +10:00.
30
+ * The schema declares `format: "date-time"` but the MCP schema-coerce layer
31
+ * does not enforce JSON Schema `format`, so we enforce here at runtime.
32
+ */
33
+ function toUtcIsoDateTime(value, paramName) {
34
+ const s = typeof value === 'string' ? value.trim() : '';
35
+ const m = ISO_INSTANT.exec(s);
36
+ const parsed = m ? Date.parse(s) : NaN;
37
+ const valid =
38
+ m &&
39
+ !Number.isNaN(parsed) &&
40
+ Number(m[1]) >= 1900 &&
41
+ // Reject dates Date.parse would roll over, e.g. 2026-02-30 -> 2 March.
42
+ new Date(
43
+ Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
44
+ ).getUTCDate() === Number(m[3]);
45
+ if (!valid) {
46
+ throw new ListEventsArgumentError(
47
+ `Invalid ${paramName}: expected an ISO 8601 datetime with "Z" or a ±hh:mm offset, e.g. "2026-01-01T00:00:00Z" (got ${JSON.stringify(String(value).slice(0, 40))}).`
48
+ );
49
+ }
50
+ return new Date(parsed).toISOString();
51
+ }
52
+
53
+ /**
54
+ * Validate the subject filter: a non-empty-safe string of bounded length that
55
+ * can be URL-encoded (a lone surrogate would make encodeURIComponent throw).
56
+ */
57
+ function assertValidSubject(subject) {
58
+ if (typeof subject !== 'string') {
59
+ throw new ListEventsArgumentError('Invalid subject: expected a string.');
60
+ }
61
+ if (subject.length > MAX_SUBJECT_LENGTH) {
62
+ throw new ListEventsArgumentError(
63
+ `Invalid subject: must be at most ${MAX_SUBJECT_LENGTH} characters.`
64
+ );
65
+ }
66
+ try {
67
+ encodeURIComponent(subject);
68
+ } catch (_e) {
69
+ throw new ListEventsArgumentError(
70
+ 'Invalid subject: contains malformed Unicode.'
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Build the $filter clause for the list-events Graph query.
77
+ *
78
+ * Backward-compatible behaviour: when no search args are supplied, the filter
79
+ * defaults to `start/dateTime ge '<now>'` so callers without parameters keep
80
+ * seeing only upcoming events. When ANY of startAfter/startBefore/subject are
81
+ * supplied, those replace the default and are AND-ed together.
82
+ *
83
+ * startAfter/startBefore must carry a zone and are normalised to UTC; invalid
84
+ * values raise before any Graph call is made. Single quotes in the subject are
85
+ * escaped via OData rules (`'` -> `''`) to prevent filter injection.
86
+ *
87
+ * Graph requires `$orderby` properties to lead the `$filter`, so a subject-only
88
+ * search gets a `start/dateTime ge '1900-…'` lead clause (which matches every
89
+ * event) to avoid an InefficientFilter error.
90
+ *
91
+ * @param {object} args - { startAfter?, startBefore?, subject? }
92
+ * @returns {string} - The complete $filter expression
93
+ */
94
+ function buildListEventsFilter(args) {
95
+ const { startAfter, startBefore, subject } = args;
96
+ const hasAnyFilter = Boolean(startAfter || startBefore || subject);
97
+
98
+ const conditions = [];
99
+
100
+ if (hasAnyFilter) {
101
+ const after = startAfter
102
+ ? toUtcIsoDateTime(startAfter, 'startAfter')
103
+ : null;
104
+ const before = startBefore
105
+ ? toUtcIsoDateTime(startBefore, 'startBefore')
106
+ : null;
107
+ if (after && before && after >= before) {
108
+ throw new ListEventsArgumentError(
109
+ 'Invalid range: startAfter must be earlier than startBefore.'
110
+ );
111
+ }
112
+ if (subject) assertValidSubject(subject);
113
+
114
+ if (after) conditions.push(`start/dateTime ge '${after}'`);
115
+ if (before) conditions.push(`start/dateTime lt '${before}'`);
116
+ if (!after && !before) {
117
+ conditions.push("start/dateTime ge '1900-01-01T00:00:00.000Z'");
118
+ }
119
+ if (subject) {
120
+ conditions.push(`contains(subject, '${escapeODataString(subject)}')`);
121
+ }
122
+ } else {
123
+ conditions.push(`start/dateTime ge '${new Date().toISOString()}'`);
124
+ }
125
+
126
+ return buildODataFilter(conditions);
127
+ }
128
+
129
+ /**
130
+ * Sort order for list-events: oldest first for upcoming or bounded windows;
131
+ * newest first when the search only looks backwards (an upper bound only, or a
132
+ * subject with no dates), so `$top` returns the most recent matches rather
133
+ * than the oldest events in the calendar.
134
+ * @param {object} args - { startAfter?, startBefore?, subject? }
135
+ * @returns {string} - The $orderby expression
136
+ */
137
+ function listEventsOrderBy(args) {
138
+ const { startAfter, startBefore, subject } = args;
139
+ const newestFirst = !startAfter && Boolean(startBefore || subject);
140
+ return newestFirst ? 'start/dateTime desc' : 'start/dateTime';
141
+ }
7
142
 
8
143
  /**
9
144
  * Normalise a Graph dateTimeTimeZone value to a canonical UTC ISO-8601 string
@@ -91,6 +226,21 @@ function formatLocal(utcIso, tz) {
91
226
  async function handleListEvents(args) {
92
227
  const count = Math.min(args.count || 10, config.MAX_RESULT_COUNT);
93
228
 
229
+ // Validate arguments before authenticating, so a bad argument is reported
230
+ // as such (and never reaches the network).
231
+ let filter;
232
+ try {
233
+ filter = buildListEventsFilter(args);
234
+ } catch (error) {
235
+ if (error instanceof ListEventsArgumentError) {
236
+ return {
237
+ content: [{ type: 'text', text: error.message }],
238
+ isError: true,
239
+ };
240
+ }
241
+ throw error;
242
+ }
243
+
94
244
  try {
95
245
  // Get access token
96
246
  const accessToken = await ensureAuthenticated();
@@ -101,8 +251,8 @@ async function handleListEvents(args) {
101
251
  // Add query parameters
102
252
  const queryParams = {
103
253
  $top: count,
104
- $orderby: 'start/dateTime',
105
- $filter: `start/dateTime ge '${new Date().toISOString()}'`,
254
+ $orderby: listEventsOrderBy(args),
255
+ $filter: filter,
106
256
  $select: config.CALENDAR_SELECT_FIELDS,
107
257
  };
108
258
 
@@ -198,3 +348,5 @@ handleListEvents.toUtcIso = toUtcIso;
198
348
  handleListEvents.formatLocal = formatLocal;
199
349
 
200
350
  module.exports = handleListEvents;
351
+ module.exports.buildListEventsFilter = buildListEventsFilter;
352
+ module.exports.listEventsOrderBy = listEventsOrderBy;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  const { callGraphAPI } = require('../utils/graph-api');
7
7
  const { ensureAuthenticated } = require('../auth');
8
+ const { buildMailboxPrefix } = require('../utils/mailbox');
8
9
 
9
10
  // Category color presets (Outlook uses these names)
10
11
  const CATEGORY_COLORS = [
@@ -436,6 +437,7 @@ async function handleDeleteCategory(args) {
436
437
  */
437
438
  async function handleApplyCategory(args) {
438
439
  const { messageId, messageIds, categories, action } = args;
440
+ const mailbox = args.sharedMailbox || args.email || null;
439
441
 
440
442
  // Support single ID or array
441
443
  const ids = messageIds || (messageId ? [messageId] : []);
@@ -477,6 +479,9 @@ async function handleApplyCategory(args) {
477
479
  }
478
480
 
479
481
  try {
482
+ // Inside the try so an invalid mailbox returns the handler's normal
483
+ // error object instead of a rejected promise.
484
+ const prefix = buildMailboxPrefix(mailbox);
480
485
  const accessToken = await ensureAuthenticated();
481
486
 
482
487
  const results = [];
@@ -491,7 +496,7 @@ async function handleApplyCategory(args) {
491
496
  const current = await callGraphAPI(
492
497
  accessToken,
493
498
  'GET',
494
- `me/messages/${id}`,
499
+ `${prefix}/messages/${id}`,
495
500
  null,
496
501
  { $select: 'categories' }
497
502
  );
@@ -507,7 +512,7 @@ async function handleApplyCategory(args) {
507
512
  }
508
513
  }
509
514
 
510
- await callGraphAPI(accessToken, 'PATCH', `me/messages/${id}`, {
515
+ await callGraphAPI(accessToken, 'PATCH', `${prefix}/messages/${id}`, {
511
516
  categories: newCategories,
512
517
  });
513
518
 
@@ -901,7 +906,7 @@ const categoriesTools = [
901
906
  {
902
907
  name: 'apply-category',
903
908
  description:
904
- "Tag or untag email messages with master categories (those created via `manage-category`). action=`set` (default) replaces the message's category set with the supplied `categories` array. action=`add` appends categories to whatever's already on the message. action=`remove` removes only the named categories, leaving the rest. Accepts either `messageId` (single) or `messageIds` (batch via Graph `$batch`). `categories` are matched by display name — names must already exist in the master list (create via `manage-category` first). Returns per-message confirmation.",
909
+ "Tag or untag email messages with master categories (those created via `manage-category`). action=`set` (default) replaces the message's category set with the supplied `categories` array. action=`add` appends categories to whatever's already on the message. action=`remove` removes only the named categories, leaving the rest. Accepts either `messageId` (single) or `messageIds` (batch via Graph `$batch`). `categories` are matched by display name — names must already exist in the target mailbox's master list. For your own mailbox, create them via `manage-category` first; for a shared mailbox, the names must already exist there (`manage-category` only manages the signed-in account's master list). Pass `sharedMailbox` (or alias `email`) to categorise messages in a shared/delegated mailbox instead of the signed-in account (requires Mail.ReadWrite.Shared + delegate access). Returns per-message confirmation.",
905
910
  annotations: {
906
911
  title: 'Apply Categories',
907
912
  readOnlyHint: false,
@@ -931,6 +936,15 @@ const categoriesTools = [
931
936
  description:
932
937
  'set (replace all), add (append), remove (remove specific). Default: set',
933
938
  },
939
+ sharedMailbox: {
940
+ type: 'string',
941
+ description:
942
+ 'Email address of a shared/delegated mailbox whose messages to categorise instead of the signed-in account. Requires delegate access + Mail.ReadWrite.Shared. Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
943
+ },
944
+ email: {
945
+ type: 'string',
946
+ description: 'Alias for `sharedMailbox`.',
947
+ },
934
948
  },
935
949
  additionalProperties: false,
936
950
  required: ['categories'],
package/config.js CHANGED
@@ -57,7 +57,6 @@ if (
57
57
  !VALID_AUDIENCE_LITERALS.has(AUTH_AUDIENCE) &&
58
58
  !TENANT_GUID_RE.test(AUTH_AUDIENCE)
59
59
  ) {
60
- // eslint-disable-next-line no-console
61
60
  console.warn(
62
61
  `[outlook-assistant] OUTLOOK_AUTH_AUDIENCE="${AUTH_AUDIENCE}" is not a recognised value. ` +
63
62
  `Expected one of: common, consumers, organizations, or a tenant GUID. ` +
@@ -65,6 +64,65 @@ if (
65
64
  );
66
65
  }
67
66
 
67
+ // Shared/delegated mailbox access is OPT-IN (work/school accounts only).
68
+ // With OUTLOOK_SHARED_MAILBOX unset, sign-in requests exactly BASE_SCOPES —
69
+ // nobody's consent prompt or token changes unless they enable it:
70
+ // OUTLOOK_SHARED_MAILBOX=read → Mail.Read.Shared
71
+ // OUTLOOK_SHARED_MAILBOX=true|readwrite|1 → Mail.Read.Shared + Mail.ReadWrite.Shared
72
+ // Sending/drafts from a shared mailbox are out of scope (Mail.Send.Shared is
73
+ // never requested). When enabled, the device-code flow falls back to
74
+ // BASE_SCOPES only on errors proving the account can't use `.Shared` scopes
75
+ // (see auth/device-code.js isScopeConsentError); consent-required errors
76
+ // (AADSTS65001) surface remediation instead of downgrading.
77
+ const ALL_SHARED_SCOPES = ['Mail.Read.Shared', 'Mail.ReadWrite.Shared'];
78
+
79
+ /**
80
+ * Parse OUTLOOK_SHARED_MAILBOX into a mode.
81
+ * @param {string|undefined} raw
82
+ * @returns {'off'|'read'|'readwrite'}
83
+ */
84
+ function parseSharedMailboxMode(raw) {
85
+ const value = String(raw || '')
86
+ .trim()
87
+ .toLowerCase();
88
+ if (value === 'read') return 'read';
89
+ if (['true', 'readwrite', '1'].includes(value)) return 'readwrite';
90
+ if (value && !['false', '0', 'off', 'no'].includes(value)) {
91
+ console.warn(
92
+ `[outlook-assistant] OUTLOOK_SHARED_MAILBOX="${raw}" is not a recognised value. ` +
93
+ 'Expected read, true/readwrite/1, or unset. Shared-mailbox support stays off.'
94
+ );
95
+ }
96
+ return 'off';
97
+ }
98
+
99
+ const SHARED_MAILBOX_MODE = parseSharedMailboxMode(
100
+ process.env.OUTLOOK_SHARED_MAILBOX
101
+ );
102
+ const SHARED_SCOPES_BY_MODE = {
103
+ off: [],
104
+ read: ['Mail.Read.Shared'],
105
+ readwrite: [...ALL_SHARED_SCOPES],
106
+ };
107
+ const SHARED_SCOPES = SHARED_SCOPES_BY_MODE[SHARED_MAILBOX_MODE];
108
+
109
+ // Base scopes consentable by ANY account type (personal + work/school).
110
+ const BASE_SCOPES = [
111
+ 'offline_access',
112
+ 'User.Read',
113
+ 'Mail.Read',
114
+ 'Mail.ReadWrite',
115
+ 'Mail.Send',
116
+ 'Calendars.Read',
117
+ 'Calendars.ReadWrite',
118
+ 'Contacts.Read',
119
+ 'Contacts.ReadWrite',
120
+ 'People.Read',
121
+ 'MailboxSettings.ReadWrite',
122
+ // Org-dependent scopes (work/school accounts only):
123
+ // 'Place.Read.All', // find-meeting-rooms tool
124
+ ];
125
+
68
126
  module.exports = {
69
127
  // Server information
70
128
  SERVER_NAME: 'outlook-assistant',
@@ -73,27 +131,25 @@ module.exports = {
73
131
  // Test mode setting
74
132
  USE_TEST_MODE: process.env.USE_TEST_MODE === 'true',
75
133
 
134
+ // OAuth scope sets (exported so tests + the fallback logic can reference them)
135
+ BASE_SCOPES,
136
+ // `.Shared` scopes requested at sign-in for the configured mode ([] = off)
137
+ SHARED_SCOPES,
138
+ ALL_SHARED_SCOPES,
139
+ // 'off' | 'read' | 'readwrite' — from OUTLOOK_SHARED_MAILBOX (opt-in)
140
+ SHARED_MAILBOX_MODE,
141
+ parseSharedMailboxMode,
142
+
76
143
  // Authentication configuration
77
144
  AUTH_CONFIG: {
78
145
  clientId: process.env.OUTLOOK_CLIENT_ID || '',
79
146
  clientSecret: process.env.OUTLOOK_CLIENT_SECRET || '',
80
147
  redirectUri: 'http://localhost:3333/auth/callback',
81
- scopes: [
82
- 'offline_access',
83
- 'User.Read',
84
- 'Mail.Read',
85
- 'Mail.ReadWrite',
86
- 'Mail.Send',
87
- 'Calendars.Read',
88
- 'Calendars.ReadWrite',
89
- 'Contacts.Read',
90
- 'Contacts.ReadWrite',
91
- 'People.Read',
92
- 'MailboxSettings.ReadWrite',
93
- // Org-dependent scopes (work/school accounts only):
94
- // 'Mail.Read.Shared', // access-shared-mailbox tool
95
- // 'Place.Read.All', // find-meeting-rooms tool
96
- ],
148
+ // Base scopes, plus the `.Shared` scopes only when OUTLOOK_SHARED_MAILBOX
149
+ // opts in. With the flag on, device-code auth falls back to
150
+ // fallbackScopes (base only) when the account rejects `.Shared`.
151
+ scopes: [...BASE_SCOPES, ...SHARED_SCOPES],
152
+ fallbackScopes: BASE_SCOPES,
97
153
  tokenStorePath: path.join(homeDir, '.outlook-assistant-tokens.json'),
98
154
  authServerUrl: 'http://localhost:3333',
99
155
  audience: AUTH_AUDIENCE,
@@ -9,6 +9,66 @@ const path = require('path');
9
9
  const _config = require('../config'); // Reserved for future use
10
10
  const { callGraphAPI } = require('../utils/graph-api');
11
11
  const { ensureAuthenticated } = require('../auth');
12
+ const { buildMailboxPrefix } = require('../utils/mailbox');
13
+
14
+ const MAX_FILENAME_LENGTH = 200;
15
+
16
+ /**
17
+ * Reduce a sender-controlled attachment name to a safe basename.
18
+ * Strips any directory part (either separator), control and reserved
19
+ * characters, and leading dots, then caps the length while keeping the
20
+ * extension. Falls back to "attachment" when nothing usable remains.
21
+ * @param {string} name - Attachment name from Graph metadata
22
+ * @returns {string} - Filename safe to join onto an output directory
23
+ */
24
+ function safeAttachmentFilename(name) {
25
+ const base = String(name || '')
26
+ .split(/[\\/]/)
27
+ .pop()
28
+ // eslint-disable-next-line no-control-regex
29
+ .replace(/[\x00-\x1f\x7f]/g, '')
30
+ .replace(/[<>:"|?*]/g, '_')
31
+ .trim()
32
+ .replace(/^\.+/, '');
33
+
34
+ if (!base) return 'attachment';
35
+ if (base.length <= MAX_FILENAME_LENGTH) return base;
36
+
37
+ const ext = path.extname(base).slice(0, 20);
38
+ return base.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext;
39
+ }
40
+
41
+ /**
42
+ * Write a buffer into outputDir without ever overwriting an existing entry
43
+ * or following a symlink: `wx` fails on any existing path (including a
44
+ * dangling symlink), so collisions get a numbered suffix instead.
45
+ * @param {string} outputDir - Target directory
46
+ * @param {string} filename - Safe basename from safeAttachmentFilename
47
+ * @param {Buffer} buffer - File contents
48
+ * @returns {string} - Absolute path actually written
49
+ */
50
+ function writeUniqueFile(outputDir, filename, buffer) {
51
+ const root = path.resolve(outputDir);
52
+ const ext = path.extname(filename);
53
+ const stem = filename.slice(0, filename.length - ext.length);
54
+
55
+ for (let i = 0; i < 1000; i++) {
56
+ const candidate = path.join(
57
+ root,
58
+ i === 0 ? filename : `${stem}-${i}${ext}`
59
+ );
60
+ if (path.dirname(candidate) !== root) {
61
+ throw new Error('Refusing to write attachment outside outputDir');
62
+ }
63
+ try {
64
+ fs.writeFileSync(candidate, buffer, { flag: 'wx' });
65
+ return candidate;
66
+ } catch (error) {
67
+ if (error.code !== 'EEXIST') throw error;
68
+ }
69
+ }
70
+ throw new Error(`Too many files named ${filename} in ${root}`);
71
+ }
12
72
 
13
73
  /**
14
74
  * List attachments for a specific email
@@ -18,6 +78,9 @@ const { ensureAuthenticated } = require('../auth');
18
78
  */
19
79
  async function handleListAttachments(args) {
20
80
  const messageId = args.messageId;
81
+ // Attachment IDs live under a mailbox-scoped message ID; route to the owning
82
+ // shared/delegated mailbox when supplied, else the signed-in account.
83
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
21
84
 
22
85
  if (!messageId) {
23
86
  return {
@@ -34,7 +97,7 @@ async function handleListAttachments(args) {
34
97
  const accessToken = await ensureAuthenticated();
35
98
 
36
99
  // Call Graph API to get attachments
37
- const endpoint = `/me/messages/${messageId}/attachments`;
100
+ const endpoint = `${prefix}/messages/${messageId}/attachments`;
38
101
  const params = {
39
102
  $select: 'id,name,contentType,size,isInline',
40
103
  };
@@ -117,6 +180,7 @@ async function handleDownloadAttachment(args) {
117
180
  // tree with downloaded files.
118
181
  const { messageId, attachmentId } = args;
119
182
  const savePath = args.outputDir || args.savePath;
183
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
120
184
 
121
185
  if (!messageId || !attachmentId) {
122
186
  return {
@@ -133,7 +197,7 @@ async function handleDownloadAttachment(args) {
133
197
  const accessToken = await ensureAuthenticated();
134
198
 
135
199
  // First, get attachment metadata to get the filename and content
136
- const metadataEndpoint = `/me/messages/${messageId}/attachments/${attachmentId}`;
200
+ const metadataEndpoint = `${prefix}/messages/${messageId}/attachments/${attachmentId}`;
137
201
  console.error(`Fetching attachment metadata: ${attachmentId}`);
138
202
 
139
203
  const metadata = await callGraphAPI(
@@ -177,13 +241,18 @@ async function handleDownloadAttachment(args) {
177
241
  // of cwd so attachments don't silently land in the source tree
178
242
  // when the caller forgets to pass outputDir. Auto-create the
179
243
  // target directory.
244
+ // The filename is sender-controlled (GHSA-755c-c45g-69rv): reduce it
245
+ // to a safe basename and never overwrite or follow a symlink.
180
246
  const outputDir = savePath || os.tmpdir();
181
247
  fs.mkdirSync(outputDir, { recursive: true });
182
- const outputPath = path.join(outputDir, filename);
183
248
 
184
249
  // Decode base64 and save to file
185
250
  const buffer = Buffer.from(contentBytes, 'base64');
186
- fs.writeFileSync(outputPath, buffer);
251
+ const outputPath = writeUniqueFile(
252
+ outputDir,
253
+ safeAttachmentFilename(filename),
254
+ buffer
255
+ );
187
256
 
188
257
  const sizeKB = (buffer.length / 1024).toFixed(1);
189
258
 
@@ -262,6 +331,7 @@ async function handleDownloadAttachment(args) {
262
331
  */
263
332
  async function handleGetAttachmentContent(args) {
264
333
  const { messageId, attachmentId } = args;
334
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
265
335
 
266
336
  if (!messageId || !attachmentId) {
267
337
  return {
@@ -277,7 +347,7 @@ async function handleGetAttachmentContent(args) {
277
347
  try {
278
348
  const accessToken = await ensureAuthenticated();
279
349
 
280
- const endpoint = `/me/messages/${messageId}/attachments/${attachmentId}`;
350
+ const endpoint = `${prefix}/messages/${messageId}/attachments/${attachmentId}`;
281
351
  console.error(`Fetching attachment content: ${attachmentId}`);
282
352
 
283
353
  const response = await callGraphAPI(accessToken, 'GET', endpoint, null, {});
@@ -372,4 +442,8 @@ module.exports = {
372
442
  handleListAttachments,
373
443
  handleDownloadAttachment,
374
444
  handleGetAttachmentContent,
445
+ // Shared with email/export.js so exported attachments get the same
446
+ // GHSA-755c-c45g-69rv filename hardening.
447
+ safeAttachmentFilename,
448
+ writeUniqueFile,
375
449
  };
@@ -12,9 +12,12 @@ const {
12
12
  } = require('../utils/graph-api');
13
13
  const { ensureAuthenticated } = require('../auth');
14
14
  const { getEmailFields } = require('../utils/field-presets');
15
+ const { resolveFolderPath } = require('./folder-utils');
16
+ const { buildMailboxPrefix } = require('../utils/mailbox');
15
17
  const {
16
18
  formatEmailContent,
17
19
  formatEmailsAsCSV,
20
+ stripHtml,
18
21
  VERBOSITY,
19
22
  } = require('../utils/response-formatter');
20
23
  // Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
@@ -58,6 +61,8 @@ async function handleListConversations(args) {
58
61
  const folder = args.folder || 'inbox';
59
62
  const count = Math.min(args.count || 20, 50);
60
63
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
64
+ // Optional: scope to a shared/delegated mailbox instead of the signed-in user.
65
+ const sharedMailbox = args.sharedMailbox || args.email || null;
61
66
 
62
67
  try {
63
68
  const accessToken = await ensureAuthenticated();
@@ -76,7 +81,13 @@ async function handleListConversations(args) {
76
81
  'bodyPreview',
77
82
  ].join(',');
78
83
 
79
- const endpoint = `me/mailFolders/${folder}/messages`;
84
+ // resolveFolderPath handles well-known names, custom/localized names, nested
85
+ // paths, and raw IDs, scoped to the signed-in user or the shared mailbox.
86
+ const endpoint = await resolveFolderPath(
87
+ accessToken,
88
+ folder,
89
+ sharedMailbox
90
+ );
80
91
  const queryParams = {
81
92
  $select: selectFields,
82
93
  $orderby: 'receivedDateTime desc',
@@ -251,6 +262,8 @@ async function handleGetConversation(args) {
251
262
  const conversationId = args.conversationId;
252
263
  const includeHeaders = args.includeHeaders || false;
253
264
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
265
+ const sharedMailbox = args.sharedMailbox || args.email || null;
266
+ const prefix = buildMailboxPrefix(sharedMailbox);
254
267
 
255
268
  if (!conversationId) {
256
269
  return {
@@ -266,7 +279,7 @@ async function handleGetConversation(args) {
266
279
  const selectFields = getEmailFields(fieldPreset);
267
280
 
268
281
  // Search all folders for messages with this conversation ID
269
- const endpoint = 'me/messages';
282
+ const endpoint = `${prefix}/messages`;
270
283
  const queryParams = {
271
284
  $select: selectFields,
272
285
  $filter: `conversationId eq '${conversationId}'`,
@@ -378,6 +391,8 @@ async function handleExportConversation(args) {
378
391
  const outputDir = args.outputDir || require('os').tmpdir();
379
392
  const _includeAttachments = args.includeAttachments !== false;
380
393
  const order = args.order || 'chronological';
394
+ const sharedMailbox = args.sharedMailbox || args.email || null;
395
+ const prefix = buildMailboxPrefix(sharedMailbox);
381
396
 
382
397
  if (!conversationId) {
383
398
  return {
@@ -402,7 +417,7 @@ async function handleExportConversation(args) {
402
417
 
403
418
  // Get all messages in conversation
404
419
  const selectFields = getEmailFields('export');
405
- const endpoint = 'me/messages';
420
+ const endpoint = `${prefix}/messages`;
406
421
  const queryParams = {
407
422
  $select: selectFields,
408
423
  $filter: `conversationId eq '${conversationId}'`,
@@ -473,7 +488,11 @@ async function handleExportConversation(args) {
473
488
 
474
489
  for (let i = 0; i < messages.length; i++) {
475
490
  const msg = messages[i];
476
- const mimeContent = await callGraphAPIRaw(accessToken, msg.id);
491
+ const mimeContent = await callGraphAPIRaw(
492
+ accessToken,
493
+ msg.id,
494
+ prefix
495
+ );
477
496
  const msgDate = formatDateForFilename(msg.receivedDateTime);
478
497
  const emlPath = path.join(
479
498
  emlDir,
@@ -492,7 +511,11 @@ async function handleExportConversation(args) {
492
511
  let mboxContent = '';
493
512
 
494
513
  for (const msg of messages) {
495
- const mimeContent = await callGraphAPIRaw(accessToken, msg.id);
514
+ const mimeContent = await callGraphAPIRaw(
515
+ accessToken,
516
+ msg.id,
517
+ prefix
518
+ );
496
519
  const from = msg.from?.emailAddress?.address || 'unknown@unknown.com';
497
520
  const msgDate = new Date(msg.receivedDateTime);
498
521
  const mboxDate = msgDate.toUTCString().replace('GMT', '+0000');
@@ -542,16 +565,7 @@ async function handleExportConversation(args) {
542
565
  // Body content
543
566
  if (msg.body?.content) {
544
567
  if (msg.body.contentType === 'html') {
545
- // Simple HTML to text conversion
546
- const text = msg.body.content
547
- .replace(/<br\s*\/?>/gi, '\n')
548
- .replace(/<\/p>/gi, '\n\n')
549
- .replace(/<[^>]+>/g, '')
550
- .replace(/&nbsp;/g, ' ')
551
- .replace(/&lt;/g, '<')
552
- .replace(/&gt;/g, '>')
553
- .replace(/&amp;/g, '&');
554
- mdContent.push(text.trim());
568
+ mdContent.push(stripHtml(msg.body.content));
555
569
  } else {
556
570
  mdContent.push(msg.body.content);
557
571
  }