@littlebearapps/outlook-assistant 3.11.2 → 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/.env.example +12 -0
- package/README.md +46 -28
- package/advanced/index.js +239 -10
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/index.js +19 -1
- package/calendar/list.js +154 -2
- package/categories/index.js +17 -3
- package/config.js +73 -17
- package/email/attachments.js +13 -3
- package/email/conversations.js +27 -5
- package/email/delta.js +94 -4
- package/email/export.js +164 -54
- package/email/folder-utils.js +29 -6
- package/email/headers.js +5 -1
- package/email/index.js +68 -13
- package/email/list.js +8 -1
- package/email/mark-as-read.js +3 -1
- package/email/mime.js +4 -1
- package/email/read.js +5 -1
- package/email/search.js +14 -5
- package/folder/create.js +11 -4
- package/folder/delete.js +9 -1
- package/folder/index.js +11 -1
- package/folder/list.js +61 -27
- package/folder/move.js +32 -7
- package/folder/resolve.js +62 -23
- package/folder/stats.js +11 -5
- package/llms-install.md +28 -9
- package/llms.txt +12 -8
- package/package.json +3 -3
- package/utils/graph-api.js +76 -3
- package/utils/mailbox.js +77 -0
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:
|
|
105
|
-
$filter:
|
|
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;
|
package/categories/index.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
package/email/attachments.js
CHANGED
|
@@ -9,6 +9,7 @@ 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');
|
|
12
13
|
|
|
13
14
|
const MAX_FILENAME_LENGTH = 200;
|
|
14
15
|
|
|
@@ -77,6 +78,9 @@ function writeUniqueFile(outputDir, filename, buffer) {
|
|
|
77
78
|
*/
|
|
78
79
|
async function handleListAttachments(args) {
|
|
79
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);
|
|
80
84
|
|
|
81
85
|
if (!messageId) {
|
|
82
86
|
return {
|
|
@@ -93,7 +97,7 @@ async function handleListAttachments(args) {
|
|
|
93
97
|
const accessToken = await ensureAuthenticated();
|
|
94
98
|
|
|
95
99
|
// Call Graph API to get attachments
|
|
96
|
-
const endpoint =
|
|
100
|
+
const endpoint = `${prefix}/messages/${messageId}/attachments`;
|
|
97
101
|
const params = {
|
|
98
102
|
$select: 'id,name,contentType,size,isInline',
|
|
99
103
|
};
|
|
@@ -176,6 +180,7 @@ async function handleDownloadAttachment(args) {
|
|
|
176
180
|
// tree with downloaded files.
|
|
177
181
|
const { messageId, attachmentId } = args;
|
|
178
182
|
const savePath = args.outputDir || args.savePath;
|
|
183
|
+
const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
|
|
179
184
|
|
|
180
185
|
if (!messageId || !attachmentId) {
|
|
181
186
|
return {
|
|
@@ -192,7 +197,7 @@ async function handleDownloadAttachment(args) {
|
|
|
192
197
|
const accessToken = await ensureAuthenticated();
|
|
193
198
|
|
|
194
199
|
// First, get attachment metadata to get the filename and content
|
|
195
|
-
const metadataEndpoint =
|
|
200
|
+
const metadataEndpoint = `${prefix}/messages/${messageId}/attachments/${attachmentId}`;
|
|
196
201
|
console.error(`Fetching attachment metadata: ${attachmentId}`);
|
|
197
202
|
|
|
198
203
|
const metadata = await callGraphAPI(
|
|
@@ -326,6 +331,7 @@ async function handleDownloadAttachment(args) {
|
|
|
326
331
|
*/
|
|
327
332
|
async function handleGetAttachmentContent(args) {
|
|
328
333
|
const { messageId, attachmentId } = args;
|
|
334
|
+
const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
|
|
329
335
|
|
|
330
336
|
if (!messageId || !attachmentId) {
|
|
331
337
|
return {
|
|
@@ -341,7 +347,7 @@ async function handleGetAttachmentContent(args) {
|
|
|
341
347
|
try {
|
|
342
348
|
const accessToken = await ensureAuthenticated();
|
|
343
349
|
|
|
344
|
-
const endpoint =
|
|
350
|
+
const endpoint = `${prefix}/messages/${messageId}/attachments/${attachmentId}`;
|
|
345
351
|
console.error(`Fetching attachment content: ${attachmentId}`);
|
|
346
352
|
|
|
347
353
|
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, {});
|
|
@@ -436,4 +442,8 @@ module.exports = {
|
|
|
436
442
|
handleListAttachments,
|
|
437
443
|
handleDownloadAttachment,
|
|
438
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,
|
|
439
449
|
};
|
package/email/conversations.js
CHANGED
|
@@ -12,6 +12,8 @@ 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,
|
|
@@ -59,6 +61,8 @@ async function handleListConversations(args) {
|
|
|
59
61
|
const folder = args.folder || 'inbox';
|
|
60
62
|
const count = Math.min(args.count || 20, 50);
|
|
61
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;
|
|
62
66
|
|
|
63
67
|
try {
|
|
64
68
|
const accessToken = await ensureAuthenticated();
|
|
@@ -77,7 +81,13 @@ async function handleListConversations(args) {
|
|
|
77
81
|
'bodyPreview',
|
|
78
82
|
].join(',');
|
|
79
83
|
|
|
80
|
-
|
|
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
|
+
);
|
|
81
91
|
const queryParams = {
|
|
82
92
|
$select: selectFields,
|
|
83
93
|
$orderby: 'receivedDateTime desc',
|
|
@@ -252,6 +262,8 @@ async function handleGetConversation(args) {
|
|
|
252
262
|
const conversationId = args.conversationId;
|
|
253
263
|
const includeHeaders = args.includeHeaders || false;
|
|
254
264
|
const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
|
|
265
|
+
const sharedMailbox = args.sharedMailbox || args.email || null;
|
|
266
|
+
const prefix = buildMailboxPrefix(sharedMailbox);
|
|
255
267
|
|
|
256
268
|
if (!conversationId) {
|
|
257
269
|
return {
|
|
@@ -267,7 +279,7 @@ async function handleGetConversation(args) {
|
|
|
267
279
|
const selectFields = getEmailFields(fieldPreset);
|
|
268
280
|
|
|
269
281
|
// Search all folders for messages with this conversation ID
|
|
270
|
-
const endpoint =
|
|
282
|
+
const endpoint = `${prefix}/messages`;
|
|
271
283
|
const queryParams = {
|
|
272
284
|
$select: selectFields,
|
|
273
285
|
$filter: `conversationId eq '${conversationId}'`,
|
|
@@ -379,6 +391,8 @@ async function handleExportConversation(args) {
|
|
|
379
391
|
const outputDir = args.outputDir || require('os').tmpdir();
|
|
380
392
|
const _includeAttachments = args.includeAttachments !== false;
|
|
381
393
|
const order = args.order || 'chronological';
|
|
394
|
+
const sharedMailbox = args.sharedMailbox || args.email || null;
|
|
395
|
+
const prefix = buildMailboxPrefix(sharedMailbox);
|
|
382
396
|
|
|
383
397
|
if (!conversationId) {
|
|
384
398
|
return {
|
|
@@ -403,7 +417,7 @@ async function handleExportConversation(args) {
|
|
|
403
417
|
|
|
404
418
|
// Get all messages in conversation
|
|
405
419
|
const selectFields = getEmailFields('export');
|
|
406
|
-
const endpoint =
|
|
420
|
+
const endpoint = `${prefix}/messages`;
|
|
407
421
|
const queryParams = {
|
|
408
422
|
$select: selectFields,
|
|
409
423
|
$filter: `conversationId eq '${conversationId}'`,
|
|
@@ -474,7 +488,11 @@ async function handleExportConversation(args) {
|
|
|
474
488
|
|
|
475
489
|
for (let i = 0; i < messages.length; i++) {
|
|
476
490
|
const msg = messages[i];
|
|
477
|
-
const mimeContent = await callGraphAPIRaw(
|
|
491
|
+
const mimeContent = await callGraphAPIRaw(
|
|
492
|
+
accessToken,
|
|
493
|
+
msg.id,
|
|
494
|
+
prefix
|
|
495
|
+
);
|
|
478
496
|
const msgDate = formatDateForFilename(msg.receivedDateTime);
|
|
479
497
|
const emlPath = path.join(
|
|
480
498
|
emlDir,
|
|
@@ -493,7 +511,11 @@ async function handleExportConversation(args) {
|
|
|
493
511
|
let mboxContent = '';
|
|
494
512
|
|
|
495
513
|
for (const msg of messages) {
|
|
496
|
-
const mimeContent = await callGraphAPIRaw(
|
|
514
|
+
const mimeContent = await callGraphAPIRaw(
|
|
515
|
+
accessToken,
|
|
516
|
+
msg.id,
|
|
517
|
+
prefix
|
|
518
|
+
);
|
|
497
519
|
const from = msg.from?.emailAddress?.address || 'unknown@unknown.com';
|
|
498
520
|
const msgDate = new Date(msg.receivedDateTime);
|
|
499
521
|
const mboxDate = msgDate.toUTCString().replace('GMT', '+0000');
|
package/email/delta.js
CHANGED
|
@@ -8,6 +8,60 @@ const { callGraphAPI } = require('../utils/graph-api');
|
|
|
8
8
|
const { ensureAuthenticated } = require('../auth');
|
|
9
9
|
const { formatEmailList, VERBOSITY } = require('../utils/response-formatter');
|
|
10
10
|
const { getEmailFields } = require('../utils/field-presets');
|
|
11
|
+
const { buildMailboxPrefix } = require('../utils/mailbox');
|
|
12
|
+
const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract the mailbox segment (`me` or `users/{address}`) from a delta/
|
|
16
|
+
* continuation token URL. Returns null when the token carries no mailbox
|
|
17
|
+
* segment we can recognise (e.g. an opaque or relative value) — those are
|
|
18
|
+
* passed through untouched for backward compatibility.
|
|
19
|
+
* @param {string} token - Delta or continuation token (a full Graph URL)
|
|
20
|
+
* @returns {string|null} - Mailbox prefix found in the token path, or null
|
|
21
|
+
*/
|
|
22
|
+
function mailboxFromToken(token) {
|
|
23
|
+
let pathname = token;
|
|
24
|
+
try {
|
|
25
|
+
pathname = new URL(token).pathname;
|
|
26
|
+
} catch {
|
|
27
|
+
// Not an absolute URL — match against the raw value.
|
|
28
|
+
}
|
|
29
|
+
const match = pathname.match(/(?:^|\/)(me|users\/[^/]+)(?:\/|$)/i);
|
|
30
|
+
if (!match) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
// Token URLs carry the percent-encoded form; local prefixes are raw.
|
|
34
|
+
// Decode so the two compare on equal footing.
|
|
35
|
+
try {
|
|
36
|
+
return decodeURIComponent(match[1]);
|
|
37
|
+
} catch {
|
|
38
|
+
return match[1];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether a delta token's mailbox clearly differs from the target.
|
|
44
|
+
* Only identifiers of the same kind are compared: `me` against `me`, or an
|
|
45
|
+
* address against an address. Graph may hand back continuation links that
|
|
46
|
+
* name the mailbox by object ID (`users/<guid>`), which can't be matched to an
|
|
47
|
+
* address locally, so those are let through rather than wrongly rejected.
|
|
48
|
+
* @param {string} tokenMailbox - Mailbox segment from the token (`me` or `users/...`)
|
|
49
|
+
* @param {string} prefix - Mailbox prefix for this call (`me` or `users/...`)
|
|
50
|
+
* @returns {boolean} - True when the two identifiably name different mailboxes
|
|
51
|
+
*/
|
|
52
|
+
function mailboxesConflict(tokenMailbox, prefix) {
|
|
53
|
+
const token = tokenMailbox.toLowerCase();
|
|
54
|
+
const target = prefix.toLowerCase();
|
|
55
|
+
if (token === target) return false;
|
|
56
|
+
const isAddress = (p) => p.startsWith('users/') && p.includes('@');
|
|
57
|
+
if (token === 'me' || target === 'me') {
|
|
58
|
+
// `me` versus a named mailbox is a mismatch, unless the named one is an
|
|
59
|
+
// opaque object ID that could be the signed-in user.
|
|
60
|
+
const other = token === 'me' ? target : token;
|
|
61
|
+
return isAddress(other);
|
|
62
|
+
}
|
|
63
|
+
return isAddress(token) && isAddress(target);
|
|
64
|
+
}
|
|
11
65
|
|
|
12
66
|
/**
|
|
13
67
|
* List emails delta handler - incremental sync
|
|
@@ -23,6 +77,10 @@ async function handleListEmailsDelta(args) {
|
|
|
23
77
|
const deltaToken = args.deltaToken;
|
|
24
78
|
const maxResults = Math.min(args.maxResults || 100, 200);
|
|
25
79
|
const verbosity = args.outputVerbosity || 'standard';
|
|
80
|
+
// Optional: scope the delta sync to a shared/delegated mailbox rather than
|
|
81
|
+
// the signed-in account. Accepts a custom/localized folder name or path.
|
|
82
|
+
const sharedMailbox = args.sharedMailbox || args.email || null;
|
|
83
|
+
const prefix = buildMailboxPrefix(sharedMailbox);
|
|
26
84
|
|
|
27
85
|
try {
|
|
28
86
|
const accessToken = await ensureAuthenticated();
|
|
@@ -32,11 +90,39 @@ async function handleListEmailsDelta(args) {
|
|
|
32
90
|
let queryParams = {};
|
|
33
91
|
|
|
34
92
|
if (deltaToken) {
|
|
35
|
-
// Continue from previous sync - use deltaLink directly
|
|
93
|
+
// Continue from previous sync - use deltaLink directly. The token is
|
|
94
|
+
// authoritative: it already encodes the mailbox and folder, so the
|
|
95
|
+
// `folder`/`sharedMailbox` args are ignored. Reject a token from a
|
|
96
|
+
// different mailbox rather than silently syncing the wrong one.
|
|
97
|
+
const tokenMailbox = mailboxFromToken(deltaToken);
|
|
98
|
+
if (tokenMailbox && mailboxesConflict(tokenMailbox, prefix)) {
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: 'text',
|
|
103
|
+
text:
|
|
104
|
+
`Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
|
|
105
|
+
'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.',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
36
110
|
endpoint = deltaToken;
|
|
37
111
|
} else {
|
|
38
|
-
// Initial sync - start fresh
|
|
39
|
-
|
|
112
|
+
// Initial sync - start fresh. Resolve the folder (well-known name,
|
|
113
|
+
// nested path, display name, or raw ID) within the target mailbox so
|
|
114
|
+
// custom subfolders work for shared mailboxes too.
|
|
115
|
+
// Resolution failures (not-found / ambiguous) carry their own actionable
|
|
116
|
+
// message; let them propagate to the handler's catch like any other error.
|
|
117
|
+
// A raw folder ID (accepted here before name resolution existed) is
|
|
118
|
+
// treated as an ID, not searched for as a display name.
|
|
119
|
+
const resolved = await resolveFolder(
|
|
120
|
+
accessToken,
|
|
121
|
+
looksLikeFolderId(folder)
|
|
122
|
+
? { id: folder, mailbox: sharedMailbox }
|
|
123
|
+
: { name: folder, mailbox: sharedMailbox }
|
|
124
|
+
);
|
|
125
|
+
endpoint = `${prefix}/mailFolders/${resolved.id}/messages/delta`;
|
|
40
126
|
queryParams = {
|
|
41
127
|
$select: getEmailFields('delta'),
|
|
42
128
|
$top: maxResults.toString(),
|
|
@@ -175,7 +261,11 @@ async function handleListEmailsDelta(args) {
|
|
|
175
261
|
],
|
|
176
262
|
_meta: {
|
|
177
263
|
syncType: isInitialSync ? 'initial' : 'incremental',
|
|
178
|
-
|
|
264
|
+
mailbox: sharedMailbox || 'me',
|
|
265
|
+
// With a token the folder comes from the token, not the `folder` arg
|
|
266
|
+
// (which is ignored) — don't echo a value we didn't use.
|
|
267
|
+
folder: isInitialSync ? folder : null,
|
|
268
|
+
folderSource: isInitialSync ? 'argument' : 'deltaToken',
|
|
179
269
|
itemCount: processedEmails.length,
|
|
180
270
|
hasMoreChanges: hasMoreChanges,
|
|
181
271
|
changesSummary: changesSummary,
|