@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.
Files changed (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
@@ -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
+ };
@@ -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 {
@@ -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
+ };
@@ -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;