@littlebearapps/outlook-assistant 3.11.2 → 3.12.1
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 +20 -0
- package/README.md +51 -28
- package/advanced/index.js +319 -46
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +51 -10
- package/calendar/list.js +146 -3
- package/calendar/update.js +65 -33
- package/categories/index.js +17 -3
- package/config.js +103 -17
- package/contacts/index.js +2 -1
- package/email/attachments.js +19 -37
- package/email/conversations.js +180 -91
- package/email/delta.js +123 -13
- package/email/draft.js +66 -9
- package/email/export.js +113 -77
- package/email/folder-utils.js +29 -129
- package/email/headers.js +5 -1
- package/email/index.js +76 -19
- 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 +23 -9
- 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 +65 -25
- package/folder/stats.js +11 -5
- package/index.js +9 -1
- package/llms-install.md +28 -9
- package/llms.txt +13 -9
- package/package.json +3 -3
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +390 -211
- package/utils/mailbox.js +77 -0
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- package/calendar/accept.js +0 -72
package/utils/mailbox.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mailbox scoping helper.
|
|
3
|
+
*
|
|
4
|
+
* Every Graph path in this server is built as `${prefix}/...`. The prefix is
|
|
5
|
+
* `me` for the signed-in account, or `users/{email}` for a shared/delegated
|
|
6
|
+
* mailbox. Keeping the construction in one place is what lets the shared-mailbox
|
|
7
|
+
* parameter be threaded through readers, writers, and folder resolution without
|
|
8
|
+
* each call site re-deciding the shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Pragmatic SMTP address / UPN shape — deliberately not full RFC 5322. The
|
|
12
|
+
// point is to keep caller input inside a single Graph path segment, so only
|
|
13
|
+
// printable ASCII is accepted: RFC 5322 `atext` in the local part minus `#`
|
|
14
|
+
// (a URL fragment delimiter), and dot-separated letters/digits/hyphens in the
|
|
15
|
+
// domain. No whitespace, control characters, `/ ? # % \`, or non-ASCII
|
|
16
|
+
// look-alikes (full-width `/`, zero-width spaces). The tool schemas advertise
|
|
17
|
+
// an email address only, so bare user GUIDs are not accepted.
|
|
18
|
+
const MAILBOX_PATTERN =
|
|
19
|
+
/^[A-Za-z0-9.!$&'*+=^_`{|}~-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
|
|
20
|
+
|
|
21
|
+
const config = require('../config');
|
|
22
|
+
|
|
23
|
+
const SHARED_MAILBOX_DISABLED_MESSAGE =
|
|
24
|
+
'Shared-mailbox support is turned off. It is opt-in and work/school only: ' +
|
|
25
|
+
'set OUTLOOK_SHARED_MAILBOX=read (read) or OUTLOOK_SHARED_MAILBOX=true ' +
|
|
26
|
+
'(read and organise) in the MCP server environment, restart the server, then ' +
|
|
27
|
+
're-authenticate with `auth action=authenticate force=true` so the token ' +
|
|
28
|
+
'carries the shared-mailbox scopes.';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate a mailbox and build its Graph resource prefix, WITHOUT checking
|
|
32
|
+
* whether shared-mailbox support is enabled. Only for paths that worked
|
|
33
|
+
* before the opt-in flag existed (access-shared-mailbox's direct read).
|
|
34
|
+
* @param {string|null} [mailbox] - Shared mailbox email address, or null/empty for the signed-in user
|
|
35
|
+
* @returns {string} - `me` or `users/{mailbox}`
|
|
36
|
+
* @throws {Error} If `mailbox` is non-empty but not a plausible email address
|
|
37
|
+
*/
|
|
38
|
+
function validateMailboxPrefix(mailbox) {
|
|
39
|
+
const trimmed = typeof mailbox === 'string' ? mailbox.trim() : mailbox;
|
|
40
|
+
if (!trimmed) {
|
|
41
|
+
return 'me';
|
|
42
|
+
}
|
|
43
|
+
if (trimmed === 'me') {
|
|
44
|
+
return 'me';
|
|
45
|
+
}
|
|
46
|
+
if (!MAILBOX_PATTERN.test(trimmed)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid mailbox "${mailbox}" — expected a shared mailbox email address (e.g. "team@contoso.com").`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
// Return the address raw: encoding happens exactly once, in the Graph
|
|
52
|
+
// client (`callGraphAPI` / `callGraphAPIRaw` encode each path segment).
|
|
53
|
+
// Pre-encoding here double-encoded addresses like `team+archive@…` into
|
|
54
|
+
// `%252B`. The pattern above already confines the value to one segment.
|
|
55
|
+
return `users/${trimmed}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the Graph resource prefix for a mailbox. A non-`me` mailbox requires
|
|
60
|
+
* shared-mailbox support to be enabled (OUTLOOK_SHARED_MAILBOX).
|
|
61
|
+
* @param {string|null} [mailbox] - Shared mailbox email address, or null/empty for the signed-in user
|
|
62
|
+
* @returns {string} - `me` or `users/{mailbox}`
|
|
63
|
+
* @throws {Error} If `mailbox` is invalid, or shared-mailbox support is off
|
|
64
|
+
*/
|
|
65
|
+
function buildMailboxPrefix(mailbox) {
|
|
66
|
+
const prefix = validateMailboxPrefix(mailbox);
|
|
67
|
+
if (prefix !== 'me' && config.SHARED_MAILBOX_MODE === 'off') {
|
|
68
|
+
throw new Error(SHARED_MAILBOX_DISABLED_MESSAGE);
|
|
69
|
+
}
|
|
70
|
+
return prefix;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
buildMailboxPrefix,
|
|
75
|
+
validateMailboxPrefix,
|
|
76
|
+
SHARED_MAILBOX_DISABLED_MESSAGE,
|
|
77
|
+
};
|
package/utils/mock-data.js
CHANGED
|
@@ -47,6 +47,9 @@ function simulateGraphAPIResponse(method, path, _data, _queryParams) {
|
|
|
47
47
|
hasAttachments: false,
|
|
48
48
|
importance: 'normal',
|
|
49
49
|
isRead: false,
|
|
50
|
+
// A draft, so draft update/send/delete pass the draft guard in
|
|
51
|
+
// test mode (they look the ID up before acting).
|
|
52
|
+
isDraft: true,
|
|
50
53
|
internetMessageHeaders: [],
|
|
51
54
|
};
|
|
52
55
|
} else {
|
package/utils/odata-helpers.js
CHANGED
|
@@ -14,6 +14,28 @@ function escapeODataString(str) {
|
|
|
14
14
|
return str.replace(/'/g, "''");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Escapes text for use inside a double-quoted Graph `$search` phrase.
|
|
19
|
+
* Graph requires `"` and `\` inside the phrase to be backslash-escaped;
|
|
20
|
+
* backslashes go first so the escapes added for quotes are not doubled. (#251)
|
|
21
|
+
* @param {string} str - Raw user text
|
|
22
|
+
* @returns {string} - Text safe to place between the phrase's quotes
|
|
23
|
+
*/
|
|
24
|
+
function escapeSearchPhrase(str) {
|
|
25
|
+
if (!str) return str;
|
|
26
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wraps user text as a double-quoted Graph `$search` phrase, escaping it.
|
|
31
|
+
* Not for caller-written `$search` expressions, which pass through as-is.
|
|
32
|
+
* @param {string} str - Raw user text
|
|
33
|
+
* @returns {string} - e.g. `Sam "the man" Lee` → `"Sam \"the man\" Lee"`
|
|
34
|
+
*/
|
|
35
|
+
function quoteSearchPhrase(str) {
|
|
36
|
+
return `"${escapeSearchPhrase(str)}"`;
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/**
|
|
18
40
|
* Builds an OData filter from filter conditions
|
|
19
41
|
* @param {Array<string>} conditions - Array of filter conditions
|
|
@@ -29,5 +51,7 @@ function buildODataFilter(conditions) {
|
|
|
29
51
|
|
|
30
52
|
module.exports = {
|
|
31
53
|
escapeODataString,
|
|
54
|
+
escapeSearchPhrase,
|
|
55
|
+
quoteSearchPhrase,
|
|
32
56
|
buildODataFilter,
|
|
33
57
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe output writes, shared by attachment download, message export and
|
|
3
|
+
* conversation export.
|
|
4
|
+
*
|
|
5
|
+
* Every file the server names itself is written with exclusive create (`wx`),
|
|
6
|
+
* so an existing file is never overwritten and a planted symlink — even a
|
|
7
|
+
* dangling one — is never followed. A clash gets a `-1`, `-2`, … suffix
|
|
8
|
+
* instead, and the result is always confined to `outputDir`.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const MAX_ATTEMPTS = 1000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Like fs.existsSync, but a dangling symlink counts as existing (existsSync
|
|
17
|
+
* follows the link and reports false).
|
|
18
|
+
* @param {string} candidate
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
function pathEntryExists(candidate) {
|
|
22
|
+
try {
|
|
23
|
+
fs.lstatSync(candidate);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build `<base>[-N][.ext]` inside `root`, refusing anything that would land
|
|
32
|
+
* outside it. Names are built from sanitised parts, but confine defensively.
|
|
33
|
+
* @param {string} root - Resolved target directory
|
|
34
|
+
* @param {string} base - Name without extension
|
|
35
|
+
* @param {string} ext - Extension with its leading dot, or ''
|
|
36
|
+
* @param {number} suffix - 0 for the plain name, else the collision number
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function candidatePath(root, base, ext, suffix) {
|
|
40
|
+
const name = suffix === 0 ? `${base}${ext}` : `${base}-${suffix}${ext}`;
|
|
41
|
+
const candidate = path.join(root, name);
|
|
42
|
+
if (path.dirname(candidate) !== root) {
|
|
43
|
+
throw new Error('Refusing to write file outside outputDir');
|
|
44
|
+
}
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Claim a not-yet-used path in `outputDir`: the plain name, else `-1`, `-2`, …
|
|
50
|
+
* until the name is free both on disk and among the paths already claimed in
|
|
51
|
+
* this batch.
|
|
52
|
+
*
|
|
53
|
+
* Silent overwrite is the dangerous part of the batch-export collision defect:
|
|
54
|
+
* the exporter reported `Successful N / Failed 0` while messages vanished.
|
|
55
|
+
* Never overwrite — disambiguate instead, and let the caller reconcile via the
|
|
56
|
+
* paths returned.
|
|
57
|
+
*
|
|
58
|
+
* The claim is synchronous, so it is atomic with respect to the event loop and
|
|
59
|
+
* safe under the batch exporter's 4-way concurrency.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} outputDir - Target directory
|
|
62
|
+
* @param {string} base - Filename without extension
|
|
63
|
+
* @param {string} extension - Extension without a leading dot ('' for none)
|
|
64
|
+
* @param {Set<string>} claimed - Paths already claimed by this batch
|
|
65
|
+
* @returns {string} - An unused absolute path, now claimed
|
|
66
|
+
*/
|
|
67
|
+
function claimUniquePath(outputDir, base, extension, claimed) {
|
|
68
|
+
const root = path.resolve(outputDir);
|
|
69
|
+
const ext = extension ? `.${extension}` : '';
|
|
70
|
+
for (let suffix = 0; suffix < MAX_ATTEMPTS; suffix++) {
|
|
71
|
+
const candidate = candidatePath(root, base, ext, suffix);
|
|
72
|
+
if (!claimed.has(candidate) && !pathEntryExists(candidate)) {
|
|
73
|
+
claimed.add(candidate);
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Too many files named ${base}${ext} in ${root}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Best-effort removal of a file this call created before its write failed
|
|
82
|
+
* (e.g. ENOSPC/EIO), so no truncated file is left under the claimed name.
|
|
83
|
+
* Only called for non-EEXIST errors: with EEXIST the entry isn't ours.
|
|
84
|
+
* @param {string} candidate
|
|
85
|
+
*/
|
|
86
|
+
function removePartialFile(candidate) {
|
|
87
|
+
try {
|
|
88
|
+
fs.unlinkSync(candidate);
|
|
89
|
+
} catch {
|
|
90
|
+
// Already gone (ENOENT) or not removable; the original error matters more.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Claim a unique name in `outputDir` and write `data` to it exclusively. The
|
|
96
|
+
* `wx` flag fails on any existing entry — including a dangling symlink planted
|
|
97
|
+
* after the claim — so a write never overwrites a file or follows a link; on
|
|
98
|
+
* EEXIST the next suffix is claimed instead. Any other write error removes the
|
|
99
|
+
* partly written file before it is rethrown.
|
|
100
|
+
* @param {string} outputDir - Target directory (must already exist)
|
|
101
|
+
* @param {string} base - Filename without extension (already sanitised)
|
|
102
|
+
* @param {string} extension - Extension without a leading dot ('' for none)
|
|
103
|
+
* @param {Set<string>|null} claimed - Paths already claimed by this batch
|
|
104
|
+
* @param {string|Buffer} data - File contents
|
|
105
|
+
* @param {string} [encoding] - Encoding for string data
|
|
106
|
+
* @returns {string} - Absolute path actually written
|
|
107
|
+
*/
|
|
108
|
+
function writeClaimedFile(outputDir, base, extension, claimed, data, encoding) {
|
|
109
|
+
const seen = claimed || new Set();
|
|
110
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
111
|
+
const candidate = claimUniquePath(outputDir, base, extension, seen);
|
|
112
|
+
try {
|
|
113
|
+
fs.writeFileSync(candidate, data, { encoding, flag: 'wx' });
|
|
114
|
+
return candidate;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error.code !== 'EEXIST') {
|
|
117
|
+
removePartialFile(candidate);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`Too many files named ${base} in ${outputDir}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a new, empty directory `<base>` (or `<base>-N`) inside `outputDir`.
|
|
127
|
+
* A non-recursive mkdir fails on any existing entry, so an export never writes
|
|
128
|
+
* into a directory it didn't just create — including a symlink pointing out of
|
|
129
|
+
* `outputDir`.
|
|
130
|
+
* @param {string} outputDir - Parent directory (must already exist)
|
|
131
|
+
* @param {string} base - Directory name (already sanitised)
|
|
132
|
+
* @returns {string} - Absolute path of the directory created
|
|
133
|
+
*/
|
|
134
|
+
function makeClaimedDir(outputDir, base) {
|
|
135
|
+
const root = path.resolve(outputDir);
|
|
136
|
+
for (let suffix = 0; suffix < MAX_ATTEMPTS; suffix++) {
|
|
137
|
+
const candidate = candidatePath(root, base, '', suffix);
|
|
138
|
+
try {
|
|
139
|
+
fs.mkdirSync(candidate);
|
|
140
|
+
return candidate;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.code !== 'EEXIST') throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`Too many directories named ${base} in ${root}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
writeClaimedFile,
|
|
150
|
+
makeClaimedDir,
|
|
151
|
+
};
|
package/calendar/accept.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Accept event functionality
|
|
3
|
-
*/
|
|
4
|
-
const { callGraphAPI } = require('../utils/graph-api');
|
|
5
|
-
const { ensureAuthenticated } = require('../auth');
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Accept event handler
|
|
9
|
-
* @param {object} args - Tool arguments
|
|
10
|
-
* @returns {object} - MCP response
|
|
11
|
-
*/
|
|
12
|
-
async function handleAcceptEvent(args) {
|
|
13
|
-
const { eventId, comment } = args;
|
|
14
|
-
|
|
15
|
-
if (!eventId) {
|
|
16
|
-
return {
|
|
17
|
-
content: [
|
|
18
|
-
{
|
|
19
|
-
type: 'text',
|
|
20
|
-
text: 'Event ID is required to accept an event.',
|
|
21
|
-
},
|
|
22
|
-
],
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
// Get access token
|
|
28
|
-
const accessToken = await ensureAuthenticated();
|
|
29
|
-
|
|
30
|
-
// Build API endpoint
|
|
31
|
-
const endpoint = `me/events/${eventId}/accept`;
|
|
32
|
-
|
|
33
|
-
// Request body
|
|
34
|
-
const body = {
|
|
35
|
-
comment: comment || 'Accepted via API',
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
// Make API call
|
|
39
|
-
await callGraphAPI(accessToken, 'POST', endpoint, body);
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
content: [
|
|
43
|
-
{
|
|
44
|
-
type: 'text',
|
|
45
|
-
text: `Event with ID ${eventId} has been successfully accepted.`,
|
|
46
|
-
},
|
|
47
|
-
],
|
|
48
|
-
};
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if (error.message === 'Authentication required') {
|
|
51
|
-
return {
|
|
52
|
-
content: [
|
|
53
|
-
{
|
|
54
|
-
type: 'text',
|
|
55
|
-
text: "Authentication required. Please use the 'authenticate' tool first.",
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
content: [
|
|
63
|
-
{
|
|
64
|
-
type: 'text',
|
|
65
|
-
text: `Error accepting event: ${error.message}`,
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
module.exports = handleAcceptEvent;
|