@littlebearapps/outlook-assistant 3.13.0 → 3.14.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 (67) hide show
  1. package/.env.example +30 -3
  2. package/README.md +67 -27
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/oauth-server.js +7 -1
  6. package/auth/token-manager.js +7 -3
  7. package/auth/token-storage.js +28 -30
  8. package/auth/tools.js +61 -82
  9. package/calendar/attendees.js +36 -0
  10. package/calendar/cancel.js +9 -25
  11. package/calendar/create.js +42 -48
  12. package/calendar/decline.js +10 -25
  13. package/calendar/delete.js +10 -25
  14. package/calendar/index.js +20 -37
  15. package/calendar/list.js +4 -16
  16. package/calendar/preview.js +461 -0
  17. package/calendar/update.js +55 -83
  18. package/categories/index.js +68 -265
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +43 -125
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +69 -46
  24. package/email/draft.js +170 -103
  25. package/email/export.js +145 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +86 -110
  29. package/email/list.js +4 -17
  30. package/email/mail-tips.js +86 -57
  31. package/email/mark-as-read.js +13 -49
  32. package/email/mime.js +39 -51
  33. package/email/read.js +16 -50
  34. package/email/search.js +47 -87
  35. package/email/send.js +82 -48
  36. package/folder/create.js +6 -25
  37. package/folder/delete.js +117 -38
  38. package/folder/index.js +19 -17
  39. package/folder/list.js +5 -17
  40. package/folder/move.js +13 -42
  41. package/folder/resolve.js +11 -6
  42. package/folder/stats.js +18 -27
  43. package/index.js +39 -45
  44. package/llms-install.md +22 -4
  45. package/llms.txt +20 -11
  46. package/outlook-auth-server.js +10 -3
  47. package/package.json +4 -1
  48. package/request-handler.js +217 -116
  49. package/rules/create.js +28 -71
  50. package/rules/index.js +52 -93
  51. package/rules/list.js +7 -19
  52. package/rules/rule-builder.js +59 -22
  53. package/rules/update.js +27 -61
  54. package/server.js +41 -0
  55. package/settings/index.js +162 -145
  56. package/tools.js +30 -0
  57. package/utils/field-presets.js +4 -2
  58. package/utils/graph-api.js +65 -22
  59. package/utils/logger.js +251 -0
  60. package/utils/mock-data.js +91 -2
  61. package/utils/read-only.js +59 -0
  62. package/utils/response-formatter.js +54 -15
  63. package/utils/risk-classes.js +324 -0
  64. package/utils/safe-write.js +372 -6
  65. package/utils/safety.js +247 -42
  66. package/utils/server-instructions.js +73 -0
  67. package/utils/tool-error.js +33 -0
@@ -2,16 +2,299 @@
2
2
  * Safe output writes, shared by attachment download, message export and
3
3
  * conversation export.
4
4
  *
5
+ * Every output path is first confined (confineOutputPath) to the system temp
6
+ * directory, ~/Downloads, ~/Documents or OUTLOOK_EXPORT_DIR, with no dotfile
7
+ * or dot-directory below them. Caller paths must be absolute or start with
8
+ * `~`; relative paths are refused rather than resolved against the server's
9
+ * working directory.
10
+ *
5
11
  * Every file the server names itself is written with exclusive create (`wx`),
6
12
  * so an existing file is never overwritten and a planted symlink — even a
7
13
  * dangling one — is never followed. A clash gets a `-1`, `-2`, … suffix
8
14
  * instead, and the result is always confined to `outputDir`.
15
+ *
16
+ * A file path the caller names (export savePath) is also created exclusively;
17
+ * it replaces an existing file only with `overwrite: true`, and never a
18
+ * symlink, a hard-linked file or anything in a dotted path (writeExplicitFile).
19
+ * A symlink as the last component of a caller path is not followed unless it
20
+ * leads to a directory, so a link to a file is refused, not written through.
21
+ *
22
+ * Files are created with mode 0600 and directories the server creates with
23
+ * 0700, set explicitly so the umask can't widen or narrow them; existing
24
+ * directories keep their mode, and a replaced file keeps the mode it had.
9
25
  */
26
+ const crypto = require('crypto');
10
27
  const fs = require('fs');
28
+ const os = require('os');
11
29
  const path = require('path');
12
30
 
31
+ /**
32
+ * A refused output path. The message says what was refused and why; nextStep
33
+ * says what to do instead. Handlers turn it into a tool error.
34
+ */
35
+ class OutputPathError extends Error {
36
+ constructor(message, nextStep) {
37
+ super(message);
38
+ this.name = 'OutputPathError';
39
+ this.nextStep = nextStep;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolve a path to where it really is: `..` is resolved, and the longest
45
+ * existing prefix goes through realpath (so symlinked directories are
46
+ * followed); the not-yet-existing remainder is appended as is.
47
+ * @param {string} target
48
+ * @returns {string} Absolute path
49
+ */
50
+ function resolveReal(target) {
51
+ const absolute = path.resolve(target);
52
+ const missing = [];
53
+ let current = absolute;
54
+ for (;;) {
55
+ try {
56
+ const real = fs.realpathSync.native(current);
57
+ return missing.length ? path.join(real, ...missing.reverse()) : real;
58
+ } catch (error) {
59
+ const parent = path.dirname(current);
60
+ if (error.code !== 'ENOENT' || parent === current) throw error;
61
+ missing.push(path.basename(current));
62
+ current = parent;
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Resolve an output target without following a symlink in its last
69
+ * component: the parent goes through resolveReal and the name is appended.
70
+ * A last-component symlink to a directory is followed (it is used as that
71
+ * directory, and confined where it leads); any other symlink — to a file,
72
+ * or dangling — is kept as the link itself, which the writers refuse.
73
+ * @param {string} absolute - Absolute path
74
+ * @returns {string}
75
+ */
76
+ function resolveTarget(absolute) {
77
+ const normalised = path.resolve(absolute);
78
+ const parent = path.dirname(normalised);
79
+ if (parent === normalised) return resolveReal(normalised); // filesystem root
80
+ const candidate = path.join(resolveReal(parent), path.basename(normalised));
81
+ let stat;
82
+ try {
83
+ stat = fs.lstatSync(candidate);
84
+ } catch (error) {
85
+ if (error.code === 'ENOENT') return candidate;
86
+ throw error;
87
+ }
88
+ if (!stat.isSymbolicLink()) return candidate;
89
+ try {
90
+ const real = fs.realpathSync.native(candidate);
91
+ if (fs.statSync(real).isDirectory()) return real;
92
+ } catch {
93
+ // Dangling or unreadable: keep the link itself.
94
+ }
95
+ return candidate;
96
+ }
97
+
98
+ /**
99
+ * Expand a leading `~` to the home directory.
100
+ * @param {string} p
101
+ * @returns {string}
102
+ */
103
+ function expandHome(p) {
104
+ if (p === '~') return os.homedir();
105
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
106
+ return path.join(os.homedir(), p.slice(2));
107
+ }
108
+ return p;
109
+ }
110
+
111
+ /**
112
+ * The directories exports and downloads may write into, each resolved to
113
+ * where it really is. Read on every call, so env changes apply at once.
114
+ * @returns {Array<{label: string, dir: string}>}
115
+ */
116
+ function allowedOutputBases() {
117
+ const home = os.homedir();
118
+ const bases = [
119
+ { label: 'the system temp directory', dir: os.tmpdir() },
120
+ { label: '~/Downloads', dir: path.join(home, 'Downloads') },
121
+ { label: '~/Documents', dir: path.join(home, 'Documents') },
122
+ ];
123
+ const exportDir = (process.env.OUTLOOK_EXPORT_DIR || '').trim();
124
+ if (exportDir) {
125
+ bases.push({
126
+ label: 'OUTLOOK_EXPORT_DIR',
127
+ dir: path.resolve(expandHome(exportDir)),
128
+ });
129
+ }
130
+ return bases.flatMap((base) => {
131
+ try {
132
+ return [{ ...base, dir: resolveReal(base.dir) }];
133
+ } catch {
134
+ return []; // Unresolvable (e.g. unreadable): not usable as a base
135
+ }
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Path segments of `child` below `parent`, or null if it is not inside.
141
+ * @param {string} parent - Resolved directory
142
+ * @param {string} child - Resolved path
143
+ * @returns {string[]|null}
144
+ */
145
+ function segmentsBelow(parent, child) {
146
+ const rel = path.relative(parent, child);
147
+ if (rel === '') return [];
148
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
149
+ return null;
150
+ }
151
+ return rel.split(path.sep);
152
+ }
153
+
154
+ /**
155
+ * Resolve an output file or directory and check it may be written: it must
156
+ * be inside an allowed base, with no dotfile or dot-directory below that
157
+ * base. Callers must write to the returned `path`, not the one passed in;
158
+ * `requested` is the caller's path (absolute, `~` expanded) for messages;
159
+ * `base` is the allowed directory it is in.
160
+ * @param {string} target - Path from the caller: absolute, or starting
161
+ * with `~`/`~/` for the home directory. Anything else is refused.
162
+ * @returns {{path: string, requested: string, base: string}}
163
+ * @throws {OutputPathError}
164
+ */
165
+ function confineOutputTarget(target) {
166
+ const absolute = typeof target === 'string' ? expandHome(target) : target;
167
+ if (typeof absolute !== 'string' || !path.isAbsolute(absolute)) {
168
+ throw new OutputPathError(
169
+ `Refusing to write to ${JSON.stringify(target)}: output paths must be absolute (or start with ~/ for the home directory). A relative path would land in the server's working directory.`,
170
+ 'Pass an absolute path, or omit the path to use the system temp directory.'
171
+ );
172
+ }
173
+ const requested = path.resolve(absolute);
174
+ let resolved;
175
+ try {
176
+ resolved = resolveTarget(absolute);
177
+ } catch (error) {
178
+ throw new OutputPathError(
179
+ `Cannot use output path ${JSON.stringify(target)}: ${error.message}`,
180
+ 'Pass a plain absolute path, or omit the path to use the system temp directory.'
181
+ );
182
+ }
183
+ const bases = allowedOutputBases();
184
+ let dotted = false;
185
+ for (const base of bases) {
186
+ const below = segmentsBelow(base.dir, resolved);
187
+ if (!below) continue;
188
+ if (!below.some((segment) => segment.startsWith('.'))) {
189
+ return { path: resolved, requested, base: base.dir };
190
+ }
191
+ dotted = true;
192
+ }
193
+
194
+ const baseList = bases.map((b) => `${b.label} (${b.dir})`).join(', ');
195
+ if (dotted) {
196
+ throw new OutputPathError(
197
+ `Refusing to write to ${resolved}: exports and attachment downloads never write to a dotfile or into a dot-directory (a name starting with ".").`,
198
+ `Choose a path without a dot-prefixed name inside one of: ${baseList}.`
199
+ );
200
+ }
201
+ const exportDirNote = process.env.OUTLOOK_EXPORT_DIR
202
+ ? ''
203
+ : ' OUTLOOK_EXPORT_DIR is not set.';
204
+ throw new OutputPathError(
205
+ `Refusing to write to ${resolved}: exports and attachment downloads can only write inside ${baseList}.${exportDirNote}`,
206
+ 'Choose a path inside one of those directories, or ask the user to set OUTLOOK_EXPORT_DIR to an absolute directory in the MCP server env and restart the server.'
207
+ );
208
+ }
209
+
210
+ /**
211
+ * confineOutputTarget, returning only the resolved path.
212
+ * @param {string} target
213
+ * @returns {string}
214
+ * @throws {OutputPathError}
215
+ */
216
+ function confineOutputPath(target) {
217
+ return confineOutputTarget(target).path;
218
+ }
219
+
220
+ /**
221
+ * Whether any segment of a resolved path starts with a dot. With `base`
222
+ * (the allowed directory the path was confined to), only the segments below
223
+ * it count, so a dotted OUTLOOK_EXPORT_DIR doesn't make every file in it
224
+ * look dotted; without one, or if the path isn't below it, every segment
225
+ * counts.
226
+ * @param {string} resolved
227
+ * @param {string} [base]
228
+ * @returns {boolean}
229
+ */
230
+ function hasDotSegment(resolved, base) {
231
+ const segments =
232
+ (base && segmentsBelow(base, resolved)) || resolved.split(path.sep);
233
+ return segments.some((segment) => segment.startsWith('.'));
234
+ }
235
+
13
236
  const MAX_ATTEMPTS = 1000;
14
237
 
238
+ /** Mode for every file exports and downloads create: owner read/write. */
239
+ const FILE_MODE = 0o600;
240
+ /** Mode for every directory they create: owner only. */
241
+ const DIR_MODE = 0o700;
242
+
243
+ /**
244
+ * Set a mode regardless of the umask. Best effort: the mode given at create
245
+ * time (narrowed by the umask, never widened) stays if the filesystem
246
+ * refuses chmod.
247
+ * @param {() => void} chmod
248
+ */
249
+ function forceMode(chmod) {
250
+ try {
251
+ chmod();
252
+ } catch {
253
+ // e.g. EPERM on a filesystem without POSIX modes
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Create `filePath` exclusively (`wx`: fails on any existing entry,
259
+ * including a symlink), set its mode, and write `data`. If the write fails
260
+ * part-way, the file this call created is removed before rethrowing.
261
+ * @param {string} filePath
262
+ * @param {string|Buffer} data
263
+ * @param {string} [encoding]
264
+ * @param {number} [mode]
265
+ */
266
+ function writeNewFile(filePath, data, encoding, mode = FILE_MODE) {
267
+ const fd = fs.openSync(filePath, 'wx', mode);
268
+ try {
269
+ forceMode(() => fs.fchmodSync(fd, mode));
270
+ fs.writeFileSync(fd, data, { encoding });
271
+ } catch (error) {
272
+ fs.closeSync(fd);
273
+ removePartialFile(filePath);
274
+ throw error;
275
+ }
276
+ fs.closeSync(fd);
277
+ }
278
+
279
+ /**
280
+ * Create `dir` and any missing parents with mode 0700. Directories that
281
+ * already exist keep their mode.
282
+ * @param {string} dir
283
+ */
284
+ function ensureOutputDir(dir) {
285
+ const target = path.resolve(dir);
286
+ const first = fs.mkdirSync(target, { recursive: true, mode: DIR_MODE });
287
+ if (!first) return; // Nothing was created
288
+ const below = segmentsBelow(first, target) || [];
289
+ let current = first;
290
+ forceMode(() => fs.chmodSync(current, DIR_MODE));
291
+ for (const segment of below) {
292
+ current = path.join(current, segment);
293
+ const created = current;
294
+ forceMode(() => fs.chmodSync(created, DIR_MODE));
295
+ }
296
+ }
297
+
15
298
  /**
16
299
  * Like fs.existsSync, but a dangling symlink counts as existing (existsSync
17
300
  * follows the link and reports false).
@@ -110,13 +393,10 @@ function writeClaimedFile(outputDir, base, extension, claimed, data, encoding) {
110
393
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
111
394
  const candidate = claimUniquePath(outputDir, base, extension, seen);
112
395
  try {
113
- fs.writeFileSync(candidate, data, { encoding, flag: 'wx' });
396
+ writeNewFile(candidate, data, encoding);
114
397
  return candidate;
115
398
  } catch (error) {
116
- if (error.code !== 'EEXIST') {
117
- removePartialFile(candidate);
118
- throw error;
119
- }
399
+ if (error.code !== 'EEXIST') throw error;
120
400
  }
121
401
  }
122
402
  throw new Error(`Too many files named ${base} in ${outputDir}`);
@@ -136,7 +416,8 @@ function makeClaimedDir(outputDir, base) {
136
416
  for (let suffix = 0; suffix < MAX_ATTEMPTS; suffix++) {
137
417
  const candidate = candidatePath(root, base, '', suffix);
138
418
  try {
139
- fs.mkdirSync(candidate);
419
+ fs.mkdirSync(candidate, { mode: DIR_MODE });
420
+ forceMode(() => fs.chmodSync(candidate, DIR_MODE));
140
421
  return candidate;
141
422
  } catch (error) {
142
423
  if (error.code !== 'EEXIST') throw error;
@@ -145,7 +426,92 @@ function makeClaimedDir(outputDir, base) {
145
426
  throw new Error(`Too many directories named ${base} in ${root}`);
146
427
  }
147
428
 
429
+ /**
430
+ * The refusal for an explicit file path that already exists.
431
+ * @param {string} filePath
432
+ * @returns {OutputPathError}
433
+ */
434
+ function fileExistsError(filePath) {
435
+ return new OutputPathError(
436
+ `File already exists: ${filePath}. Nothing was written.`,
437
+ 'Pass overwrite: true to replace it, choose a different savePath, or pass a directory as savePath so a new, unique file name is used.'
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Write `data` to a file path the caller chose (already confined). A new file
443
+ * is created exclusively. An existing one is replaced only with
444
+ * `overwrite: true`, and only if it is a regular file with a single link and
445
+ * no segment of its path (below `base`, when given) starts with a dot. The replacement is written to a
446
+ * temporary file beside it and renamed over it, so a link swapped in after
447
+ * the check is replaced, not followed.
448
+ * @param {string} filePath - Resolved target path
449
+ * @param {string|Buffer} data - File contents
450
+ * @param {{overwrite?: boolean, encoding?: string, displayPath?: string, base?: string}} [options]
451
+ * displayPath: the path as the caller gave it, used in refusals;
452
+ * base: the allowed directory from confineOutputTarget (dot check below it)
453
+ * @returns {{path: string, replaced: boolean}}
454
+ * @throws {OutputPathError} When the file exists and may not be replaced
455
+ */
456
+ function writeExplicitFile(
457
+ filePath,
458
+ data,
459
+ { overwrite = false, encoding, displayPath = filePath, base } = {}
460
+ ) {
461
+ const dir = path.dirname(filePath);
462
+ ensureOutputDir(dir);
463
+ try {
464
+ writeNewFile(filePath, data, encoding);
465
+ return { path: filePath, replaced: false };
466
+ } catch (error) {
467
+ if (error.code !== 'EEXIST') throw error;
468
+ }
469
+
470
+ if (!overwrite) throw fileExistsError(displayPath);
471
+ if (hasDotSegment(filePath, base)) {
472
+ throw new OutputPathError(
473
+ `Refusing to replace ${displayPath}: files that are dotfiles or inside a dot-directory are never replaced, even with overwrite: true. Nothing was written.`,
474
+ 'Choose a different savePath, or pass a directory so a new, unique file name is used.'
475
+ );
476
+ }
477
+ const stat = fs.lstatSync(filePath);
478
+ let problem = null;
479
+ if (stat.isSymbolicLink()) problem = 'it is a symbolic link';
480
+ else if (!stat.isFile()) problem = 'it is not a regular file';
481
+ else if (stat.nlink > 1) problem = 'it has other hard links';
482
+ if (problem) {
483
+ throw new OutputPathError(
484
+ `Refusing to replace ${displayPath}: ${problem}. Nothing was written.`,
485
+ 'Choose a different savePath, or pass a directory so a new, unique file name is used.'
486
+ );
487
+ }
488
+
489
+ const temp = path.join(
490
+ dir,
491
+ `.${path.basename(filePath)}.${crypto.randomBytes(6).toString('hex')}.tmp`
492
+ );
493
+ try {
494
+ // The replacement keeps the mode the file had.
495
+ writeNewFile(temp, data, encoding, stat.mode & 0o777);
496
+ fs.renameSync(temp, filePath);
497
+ } catch (error) {
498
+ removePartialFile(temp);
499
+ throw error;
500
+ }
501
+ return { path: filePath, replaced: true };
502
+ }
503
+
148
504
  module.exports = {
505
+ FILE_MODE,
506
+ DIR_MODE,
507
+ ensureOutputDir,
149
508
  writeClaimedFile,
150
509
  makeClaimedDir,
510
+ writeExplicitFile,
511
+ confineOutputPath,
512
+ confineOutputTarget,
513
+ allowedOutputBases,
514
+ fileExistsError,
515
+ pathEntryExists,
516
+ OutputPathError,
151
517
  };