使用Microsoft Graph代发邮件的权限问题及401错误排查求助
问题:Outlook认证后调用Microsoft Graph API发送邮件返回401错误
近期实现功能时遇到问题:允许用户通过Outlook邮箱完成认证,之后由API代表已认证用户预约发送邮件。目前认证流程正常,能成功获取并保存access token,调用/me接口也能正常返回用户数据,但尝试发送邮件时出现401错误:
{"statusCode":401,"code":null,"requestId":null,"date":"2023-04-21T14:18:46.748Z","body":{"_writeState":{"0":0,"1":0},"_readableState":{"objectMode":false,"highWaterMark":16384,"buffer":{"head":null,"tail":null,"length":0},"length":0,"pipes":null,"pipesCount":0,"flowing":null,"ended":false,"endEmitted":false,"reading":false,"sync":false,"needReadable":false,"emittedReadable":false,"readableListening":false,"resumeScheduled":false,"emitClose":true,"autoDestroy":false,"destroyed":false,"defaultEncoding":"utf8","awaitDrainWriters":null,"multiAwaitDrain":false,"readingMore":false,"decoder":null,"encoding":null},"readable":true,"_events":{"error":[null,null]},"_eventsCount":6,"_writableState":{"objectMode":false,"highWaterMark":16384,"finalCalled":false,"needDrain":false,"ending":false,"ended":false,"finished":false,"destroyed":false,"decodeStrings":true,"defaultEncoding":"utf8","length":10,"writing":true,"corked":0,"sync":false,"bufferProcessing":false,"writelen":10,"afterWriteTickInfo":null,"bufferedRequest":null,"lastBufferedRequest":null,"pendingcb":1,"prefinished":false,"errorEmitted":false,"emitClose":true,"autoDestroy":false,"bufferedRequestCount":0,"corkedRequestsFree":{"next":null,"entry":null}},"writable":true,"allowHalfOpen":true,"_transformState":{"needTransform":false,"transforming":true,"writechunk":{"type":"Buffer","data":[31,139,8,0,0,0,0,0,4,10]},"writeencoding":"buffer"},"_hadError":false,"bytesWritten":0,"_handle":{"buffer":{"type":"Buffer","data":[31,139,8,0,0,0,0,0,4,10]},"availOutBefore":16384,"availInBefore":10,"inOff":0,"flushFlag":2},"_outBuffer":{"type":"Buffer","data":[85,85,85,85,85,85,80,0,0,0,0,0,0,0]},"_outOffset":0,"_chunkSize":16384,"_defaultFlushFlag":2,"_finishFlushFlag":2,"_defaultFullFlushFlag":3,"_maxOutputLength":2147483647,"_level":-1,"_strategy":0}}
推测是权限配置问题,已尝试多种权限(含代码中注释的所有权限),但仍报错,寻求排查建议。
认证及发送邮件代码
配置及工具函数
import * as msal from "@azure/msal-node"; import { Client } from "@microsoft/microsoft-graph-client"; export const msalConfig: msal.Configuration = { auth: { clientId: “CLIENT_ID”, authority: "https://login.microsoftonline.com/TENENT_ID”, clientSecret: “CLIENT_SECRET”, }, }; export const cca = new msal.ConfidentialClientApplication(msalConfig); export const getGraphClient = (accessToken: string | null) => { const graphClient = Client.init({ authProvider: (done) => { done(null, accessToken); }, }); return graphClient; }; export const protectedResources = { graphMe: { meEndpoint: "https://graph.microsoft.com/v1.0/me", usersEndpoint: "https://graph.microsoft.com/v1.0/users/", scopes: [ "openid", "profile", "user.read", "mail.send", "offline_access", // "Mail.Send", // "https://graph.microsoft.com/.default", // "https://graph.microsoft.com/Mail.Send.Shared", // "https://graph.microsoft.com/Mail.Read", // "https://graph.microsoft.com/profile", // "https://graph.microsoft.com/.default", // "https://outlook.office.com/IMAP.AccessAsUser.All", ], }, };
启动认证流程
// Code to start the authentication flow const state = request.query.state as string; try { // Get the authorization URL const authCodeUrlParameters: msal.AuthorizationUrlRequest = { scopes: protectedResources.graphMe.scopes, redirectUri: "https://example.com/outlookOauth2Callback", codeChallengeMethod: "S256", state: state, }; const authorizeUrl = await cca.getAuthCodeUrl(authCodeUrlParameters); response.redirect(authorizeUrl); return; } catch (error) { console.log(error); response.status(500).send(error); return; }
获取Access Token
// Code to get the access Token const code = request.query.code as string; const state = request.query.state as string; try { const tokenRequest: msal.AuthorizationCodeRequest = { code: code, scopes: protectedResources.graphMe.scopes, redirectUri: "https://example.com/outlookOauth2Callback", codeVerifier: request.query.code_verifier as string, }; const accessToken = await cca.acquireTokenByCode(tokenRequest); await saveUserToken(code, accessToken); console.log("Successfully authorized"); response.redirect("https://example.com/callback.html?code=" + code + "&state=" + state); return; } catch (error) { console.log(error); response.status(500).send(error); return; }
发送邮件代码
// Code to send the email const graphUser = await getGraphClient(token.accessToken) .api(protectedResources.graphMe.meEndpoint) .get(); functions.logger.log("Graph User: " + JSON.stringify(graphUser)); const message = { subject: subject, body: { contentType: "HTML", content: body, }, toRecipients: [ { emailAddress: { toName: toName, address: toEmail, }, }, ], from: { emailAddress: { fromName: fromName, address: graphUser.email } }, }; const response = await getGraphClient(token[1].accessToken) .api(protectedResources.graphMe.usersEndpoint + `${graphUser.id}/sendMail`) .post({ message }); functions.logger.log("Response: " + JSON.stringify(response));
排查建议
修正Token引用错误:发送邮件时使用了
token[1].accessToken,但调用/me接口用的是token.accessToken,确认saveUserToken保存的Token结构是否正确。如果保存的是完整的AuthenticationResult对象,token[1]是无效引用,应统一使用token.accessToken。验证权限范围正确性:
- 确保Azure AD应用中已添加
Mail.Send委托权限,且完成了管理员同意(租户内应用场景)。 - 检查获取Token时返回的
scope字段,确认包含https://graph.microsoft.com/Mail.Send(Graph API权限区分大小写,小写mail.send无效)。
- 确保Azure AD应用中已添加
更换API端点:
- 代表当前认证用户发送邮件,直接使用
/me/sendMail端点即可,无需调用/users/{id}/sendMail(后者多用于应用权限或代表其他用户发送的场景)。修改代码:const response = await getGraphClient(token.accessToken) .api("https://graph.microsoft.com/v1.0/me/sendMail") .post({ message });
- 代表当前认证用户发送邮件,直接使用
检查Token有效性:
- 解析Token查看
exp字段,确认发送邮件时Token未过期;若为离线场景,确保已获取refresh_token,并在Token过期时调用acquireTokenByRefreshToken刷新。
- 解析Token查看
精简权限范围:保留
User.Read、Mail.Send、offline_access即可,移除openid、profile等冗余权限,避免权限冲突。
内容的提问来源于stack exchange,提问作者Lucian Simo
相关产品推荐
相关产品推荐

