Skip to content

Fix auth refresh, nested folder lookup, and Mail.ReadWrite scope - #62

Open
siriomaquea wants to merge 5 commits into
ryaker:mainfrom
siriomaquea:fix/auth-refresh-subfolder-readwrite
Open

Fix auth refresh, nested folder lookup, and Mail.ReadWrite scope#62
siriomaquea wants to merge 5 commits into
ryaker:mainfrom
siriomaquea:fix/auth-refresh-subfolder-readwrite

Conversation

@siriomaquea

@siriomaquea siriomaquea commented May 24, 2026

Copy link
Copy Markdown

Summary

Three independent fixes found running this MCP in production:

  1. check-auth-status now refreshes expired tokens. Previously it used the sync loadTokenCache which returns null when expires_at is in the past, causing it to report Not authenticated while a valid refresh_token was sitting in the same file. Switched to ensureAuthenticated() (same code path the other tools already use).
  2. getFolderIdByName walks into subfolders. me/mailFolders only returns top-level folders, so moving to common destinations like To Delete (under Inbox) or Archive/2024 failed with folder not found. After the top-level miss, iterate folders with childFolderCount > 0 and look up children.
  3. Auth server now requests Mail.ReadWrite. config.js already lists it but outlook-auth-server.js didn't, so issued tokens couldn't move/delete drafts. Add the scope so users re-consent once and writes work.

Each fix is a separate commit for easier review.

Test plan

  • Manual: check-auth-status after token expiry → returns Authenticated and ready (refresh fires).
  • Manual: move-emails to To Delete (subfolder of Inbox) → resolves correctly.
  • Manual: After re-auth with new scope, move-emails actually moves (no 403).
  • Existing npm test suite continues to pass.

Notes

Users will need to re-authenticate once after upgrading so the new Mail.ReadWrite scope is granted. Worth calling out in the release notes.

Summary by CodeRabbit

  • Bug Fixes

    • Improved authentication checks to better recover from expired tokens and provide clearer authenticated/unauthenticated responses.
    • Enhanced folder lookup to support slash-separated path names and more robust case-insensitive searches across folder hierarchies.
  • New Features

    • Added Mail.ReadWrite permission to enable expanded mail operations.

Review Change Stack

Previously handleCheckAuthStatus used the sync loadTokenCache helper, which
returns null when the access_token is past expires_at. This caused the tool
to report 'Not authenticated' for any client gating on it 1h after auth,
even with a valid refresh_token sitting in the same file.

Use ensureAuthenticated() (same path the other tools take) so the
refresh_token grant runs when needed. Lazy-require ./index to avoid a
circular dependency with auth/index.js.
me/mailFolders only returns top-level folders, so moving emails to
common destinations like 'To Delete' (under Inbox) or 'Archive/2024'
failed with 'folder not found'.

After the top-level search misses, iterate over folders with
childFolderCount > 0 and look up children via
me/mailFolders/{id}/childFolders. Also $select id, displayName,
childFolderCount on the top-level fetch to keep the response small.
config.js already lists Mail.ReadWrite among the required scopes for
mail-write operations (move, delete drafts, etc.), but the OAuth server
was issuing tokens without it. Resulting tokens hit 403 on any write
endpoint.

Add Mail.ReadWrite to AUTH_CONFIG.scopes so users have to re-consent
once and then write operations work as documented.
@codeant-ai

codeant-ai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

User does not have a PR Review subscription.

Go to Team management and add this email to the PR Review subscription.

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds Mail.ReadWrite to Graph scopes; replaces auth token-cache inspection with a lazy call to ensureAuthenticated() in the auth status handler; extends folder lookup to support slash-separated paths, escapes single quotes in filters, and adds case-insensitive top-level and one-level child-folder search.

Changes

Mail Auth and Folder Resolution

Layer / File(s) Summary
OAuth Scope Configuration
outlook-auth-server.js
Mail.ReadWrite scope is added to the Microsoft Graph authorization request scopes.
Authentication Status Determination
auth/tools.js
handleCheckAuthStatus now lazy-requires and calls ensureAuthenticated() to refresh/obtain access tokens and returns UNAUTHORIZED when no token is returned or when the call throws; logs progress and errors.
Folder Path and Child Search
email/folder-utils.js
getFolderIdByName supports slash-separated paths (delegates to getFolderIdByPath), escapes single quotes in exact-match filters, performs case-insensitive top-level matching with a reduced projection, and if needed searches one level of child folders in parallel under parents with children.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • ryaker/outlook-mcp#47: Related changes to ensureAuthenticated()/TokenStorage-based token acquisition and refresh behavior.
  • ryaker/outlook-mcp#38: Adds mail write flows that align with introducing Mail.ReadWrite scope.

Suggested labels

size:L

Poem

🐰 Tokens hop and folders bloom,
Paths split soft at morning's room.
Scopes expand to write and send,
Casefold searches find the end.
A little rabbit signs: “All set!” ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the three main fixes in the changeset: auth refresh, nested folder lookup, and Mail.ReadWrite scope addition.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves the authentication status check by utilizing ensureAuthenticated to handle token refreshes and adds the Mail.ReadWrite scope to the configuration. It also enhances folder lookup by searching one level deep into subfolders. Feedback was provided to optimize the subfolder search by performing API calls in parallel using Promise.all and applying a $select clause to minimize data transfer.

Comment thread email/folder-utils.js
Comment on lines +107 to +129
const foldersWithChildren = allFoldersResponse.value.filter(f => f.childFolderCount > 0);
for (const parentFolder of foldersWithChildren) {
try {
const childResponse = await callGraphAPI(
accessToken,
'GET',
`me/mailFolders/${parentFolder.id}/childFolders`,
null,
{ $top: 100 }
);
if (childResponse.value) {
const childMatch = childResponse.value.find(
f => f.displayName.toLowerCase() === lowerFolderName
);
if (childMatch) {
console.error(`Found child folder "${folderName}" under "${parentFolder.displayName}" with ID: ${childMatch.id}`);
return childMatch.id;
}
}
} catch (err) {
console.error(`Error searching child folders of "${parentFolder.displayName}": ${err.message}`);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation performs sequential API calls for each folder that has children, which can lead to significant performance degradation if a user has many top-level folders (up to 100 based on the $top parameter). Using Promise.all to fetch child folders in parallel is much more efficient. Additionally, adding a $select clause to the child folder query will reduce the payload size and improve performance, consistent with the optimization made at line 92. Note that this implementation only searches one level deep into subfolders; if deeper nesting support is required (e.g., Inbox/Sub1/Sub2), a recursive approach would be necessary.

      const foldersWithChildren = allFoldersResponse.value.filter(f => f.childFolderCount > 0);
      const childResults = await Promise.all(foldersWithChildren.map(async (parent) => {
        try {
          const res = await callGraphAPI(
            accessToken,
            'GET',
            `me/mailFolders/${parent.id}/childFolders`,
            null,
            { $top: 100, $select: 'id,displayName,childFolderCount' }
          );
          return { parent, children: res.value || [] };
        } catch (err) {
          console.error(`Error searching child folders of "${parent.displayName}": ${err.message}`);
          return { parent, children: [] };
        }
      }));

      for (const { parent, children } of childResults) {
        const match = children.find(f => f.displayName.toLowerCase() === lowerFolderName);
        if (match) {
          console.error(`Found child folder "${folderName}" under "${parent.displayName}" with ID: ${match.id}`);
          return match.id;
        }
      }

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@auth/tools.js`:
- Around line 64-80: The auth check in the try/catch around
ensureAuthenticated() (log tag '[CHECK-AUTH-STATUS]') returns a plain "Not
authenticated" payload on both the no-token and exception branches; change both
failure return paths to return the standard UNAUTHORIZED error response (i.e.,
replace the content payload with the repo's UNAUTHORIZED error message/object)
so callers can rely on the repo's auth error contract when ensureAuthenticated()
returns falsy or throws.

In `@email/folder-utils.js`:
- Around line 106-123: The current logic filters foldersWithChildren and
compares each childResponse.value displayName to lowerFolderName (derived from
folderName), which fails for path-style inputs like "Archive/2024"; instead
split folderName into path segments (e.g., parts = folderName.split('/') with
trimming), then for each parentFolder in foldersWithChildren start from the
appropriate root (e.g., match parentFolder.displayName to the first segment) and
iteratively call callGraphAPI('GET', `me/mailFolders/${currentId}/childFolders`,
...) to walk each segment, matching child.displayName (case-insensitive) to the
next segment until you either find the final folder id or stop; update the logic
around parentFolder, childResponse and childMatch to perform segment-by-segment
traversal rather than a single full-string comparison.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b3b659f4-6c0c-47a6-805c-4fa209ad3d68

📥 Commits

Reviewing files that changed from the base of the PR and between 95d6ff2 and 1abaec4.

📒 Files selected for processing (3)
  • auth/tools.js
  • email/folder-utils.js
  • outlook-auth-server.js

Comment thread auth/tools.js
Comment thread email/folder-utils.js Outdated
Both the no-token and exception branches were emitting 'Not authenticated',
which deviates from the repo's standard auth error contract (UNAUTHORIZED).
Align both failure returns so callers can rely on the contract.

Addresses CodeRabbit review feedback on PR ryaker#62.
Two improvements to getFolderIdByName based on review feedback:

1. Path-style input (e.g. 'Archive/2024', 'Inbox/To Delete') is now
   detected and walked segment-by-segment via a new helper,
   getFolderIdByPath. The previous implementation compared each child
   folder's displayName against the full input, so any value containing
   '/' never matched.

2. Child folder lookups now run in parallel via Promise.all and pass
   \$select=id,displayName,childFolderCount to keep payloads small.
   The previous sequential for-await loop became slow for users with
   many top-level folders.

Also escape single quotes when building the displayName \$filter so
folder names containing apostrophes don't break the query.

Addresses CodeRabbit and Gemini review feedback on PR ryaker#62.
@siriomaquea

Copy link
Copy Markdown
Author

Thanks for the reviews! I've addressed the feedback in two follow-up commits:

auth/tools.jshandleCheckAuthStatus now returns UNAUTHORIZED on both the no-token and exception paths, matching the repo's standard auth error contract.

email/folder-utils.js — two changes:

  • Path-style inputs like Archive/2024 or Inbox/To Delete are now detected and walked segment-by-segment via a new getFolderIdByPath helper. The previous implementation compared each child's displayName against the full input, so anything containing / never matched.
  • Child folder lookups for the single-name case now run in parallel via Promise.all and pass $select=id,displayName,childFolderCount to keep payloads small.

Also escaped single quotes in the displayName $filter so folder names with apostrophes don't break the query.

Tested locally against my mailbox — single names, nested paths, and the case-insensitive fallback all resolve correctly.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@email/folder-utils.js`:
- Around line 72-77: The split/trim logic creates a normalized segment but when
segments.length === 1 the code continues to use the original folderName (e.g.,
"Archive/") for matching; update the code paths that do exact/case-insensitive
comparisons (the logic around segments, getFolderIdByPath, and the comparisons
at the other occurrences) to use a normalizedName = segments[0] when
segments.length === 1 so all match checks (exact and case-insensitive) operate
on the trimmed single segment; apply the same normalization change to the other
comparison sites referenced around the same block (the checks near the other
occurrences) so single-segment inputs are consistently normalized before
matching.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 538ef9bd-7010-46cc-b3cc-0cbf97fcb160

📥 Commits

Reviewing files that changed from the base of the PR and between 1abaec4 and bc9a20d.

📒 Files selected for processing (2)
  • auth/tools.js
  • email/folder-utils.js

Comment thread email/folder-utils.js
Comment on lines +72 to +77
// Detect path-style input (e.g. "Archive/2024", "Inbox/To Delete") and
// walk it segment-by-segment instead of treating the whole string as one name.
const segments = folderName.split('/').map(s => s.trim()).filter(Boolean);
if (segments.length > 1) {
return await getFolderIdByPath(accessToken, segments);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize single-segment path input before matching.

After splitting/trimming segments, single-segment values like "Archive/" still use raw folderName ("Archive/") for exact and case-insensitive comparison, which can miss valid folders.

Suggested fix
-    const segments = folderName.split('/').map(s => s.trim()).filter(Boolean);
+    const segments = folderName.split('/').map(s => s.trim()).filter(Boolean);
+    if (segments.length === 0) {
+      return null;
+    }
+    const normalizedFolderName = segments[0];
     if (segments.length > 1) {
       return await getFolderIdByPath(accessToken, segments);
     }

     // Single-segment: exact match filter first
@@
-      { $filter: `displayName eq '${folderName.replace(/'/g, "''")}'` }
+      { $filter: `displayName eq '${normalizedFolderName.replace(/'/g, "''")}'` }
@@
-      const lowerFolderName = folderName.toLowerCase();
+      const lowerFolderName = normalizedFolderName.toLowerCase();

Also applies to: 85-85, 104-104

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@email/folder-utils.js` around lines 72 - 77, The split/trim logic creates a
normalized segment but when segments.length === 1 the code continues to use the
original folderName (e.g., "Archive/") for matching; update the code paths that
do exact/case-insensitive comparisons (the logic around segments,
getFolderIdByPath, and the comparisons at the other occurrences) to use a
normalizedName = segments[0] when segments.length === 1 so all match checks
(exact and case-insensitive) operate on the trimmed single segment; apply the
same normalization change to the other comparison sites referenced around the
same block (the checks near the other occurrences) so single-segment inputs are
consistently normalized before matching.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant