From df9dd80bb33049c70b99260598f8c7e2b16f1881 Mon Sep 17 00:00:00 2001 From: youbin Date: Thu, 2 Apr 2026 21:27:16 +0800 Subject: [PATCH] add user creation and mail body APIs --- .env.example | 1 + README.md | 11 +++- server.js | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 61a0e38..f916a08 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,6 @@ TENANT_ID=your-tenant-id CLIENT_ID=your-client-id CLIENT_SECRET=your-client-secret MAILBOX_ADDRESS= +DEFAULT_DOMAIN= ACCESS_PASSWORD=change-this-password PORT=3000 diff --git a/README.md b/README.md index efe3e62..2f3a156 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - 已授予并管理员同意以下 Microsoft Graph Application Permissions: - `Mail.Read` - `User.Read.All` + - `User.ReadWrite.All` ## 配置 @@ -23,6 +24,7 @@ TENANT_ID=your-tenant-id CLIENT_ID=your-client-id CLIENT_SECRET=your-client-secret MAILBOX_ADDRESS= +DEFAULT_DOMAIN= ACCESS_PASSWORD=change-this-password PORT=3000 ``` @@ -30,6 +32,7 @@ PORT=3000 说明: - `MAILBOX_ADDRESS` 为可选默认选中账号。页面首次加载完成后,如果能在租户中找到这个邮箱地址,会自动展示它的最新邮件。 +- `DEFAULT_DOMAIN` 为创建随机用户时使用的域名。未填写时,程序会尝试从 `MAILBOX_ADDRESS` 提取域名。 - `ACCESS_PASSWORD` 为访问控制台所需的口令。设置后,用户必须先输入该密码才能打开页面和访问 API。 - 读取租户账号和邮件实际依赖的是 `TENANT_ID`、`CLIENT_ID`、`CLIENT_SECRET`。 @@ -42,13 +45,15 @@ PORT=3000 | `CLIENT_SECRET` | 是 | 应用客户端密钥,用于应用身份认证 | Azure AD「证书和密码」 | | `Mail.Read` | 是 | 读取邮箱内容所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 | | `User.Read.All` | 是 | 列出租户成员账号所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 | +| `User.ReadWrite.All` | 是 | 创建随机用户所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 | | `ACCESS_PASSWORD` | 可选 | 控制台访问密码,设置后访问页面前必须先登录 | 自定义 | 补充说明: -- 这两个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。 +- 这三个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。 - 本项目采用 `client_credentials` 模式,不需要配置重定向 URI。 - 如果只配置 `Mail.Read`,页面可以读邮件,但无法列出所有账号;如果只配置 `User.Read.All`,页面可以列出账号,但无法读取邮件正文。 +- 创建随机用户需要 `User.ReadWrite.All`,并且目标域名必须是租户可用域名。 ## 运行 @@ -89,6 +94,10 @@ docker compose down - 返回租户成员账号列表 - `GET /api/users/:userId/latest-email` - 返回该账号收件箱最新一封邮件 +- `GET /api/users/:userId/latest-email/body` + - 返回该账号收件箱最新一封邮件正文 +- `POST /api/users/random` + - 自动创建一个随机用户名的新用户,并返回用户主体与临时密码 ## 注意事项 diff --git a/server.js b/server.js index 3c35e9d..4c181fd 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,7 @@ const { CLIENT_ID, CLIENT_SECRET, MAILBOX_ADDRESS = '', + DEFAULT_DOMAIN = '', ACCESS_PASSWORD = '', PORT = '3000', } = process.env; @@ -46,6 +47,7 @@ const tokenCache = { accessToken: null, expiresAt: 0, }; +let cachedCreateUserDomain = DEFAULT_DOMAIN || ''; function timingSafeStringEqual(left, right) { const leftBuffer = Buffer.from(left); @@ -154,7 +156,7 @@ function parseResponseBody(text) { } } -async function curlJsonRequest(url, { method = 'GET', headers = {}, form = null } = {}) { +async function curlJsonRequest(url, { method = 'GET', headers = {}, form = null, json = null, body = null } = {}) { const args = [ '-sS', '-L', @@ -180,6 +182,13 @@ async function curlJsonRequest(url, { method = 'GET', headers = {}, form = null }); } + if (json !== null && json !== undefined) { + args.push('-H', 'Content-Type: application/json'); + args.push('--data', JSON.stringify(json)); + } else if (body !== null && body !== undefined) { + args.push('--data', body); + } + args.push(url); try { @@ -247,6 +256,8 @@ async function graphRequest(pathname, query, init = {}) { Authorization: `Bearer ${accessToken}`, ...(init.headers || {}), }, + json: init.json ?? null, + body: init.body ?? null, }); if (response.status < 200 || response.status >= 300) { @@ -259,6 +270,41 @@ async function graphRequest(pathname, query, init = {}) { return response.body; } +function buildRandomSuffix() { + return crypto.randomBytes(4).toString('hex'); +} + +function buildTemporaryPassword() { + return `${crypto.randomBytes(12).toString('base64url')}Aa1!`; +} + +function extractDomainFromMailbox() { + if (DEFAULT_DOMAIN) { + return DEFAULT_DOMAIN; + } + + const atIndex = MAILBOX_ADDRESS.indexOf('@'); + if (atIndex !== -1) { + return MAILBOX_ADDRESS.slice(atIndex + 1); + } + + return ''; +} + +function generateRandomUserPayload() { + const suffix = buildRandomSuffix(); + const mailNickname = `office365-${suffix}`; + const domain = extractDomainFromMailbox(); + const userPrincipalName = domain ? `${mailNickname}@${domain}` : ''; + + return { + displayName: `Office365 User ${suffix}`, + mailNickname, + userPrincipalName, + password: buildTemporaryPassword(), + }; +} + async function listUsers() { const users = []; let nextUrl = buildGraphUrl('/users', { @@ -358,6 +404,64 @@ async function getLatestEmail(userId) { }; } +async function resolveDomainForUserCreation() { + if (cachedCreateUserDomain) { + return cachedCreateUserDomain; + } + + cachedCreateUserDomain = extractDomainFromMailbox(); + return cachedCreateUserDomain; +} + +async function createRandomUser() { + const domain = await resolveDomainForUserCreation(); + + if (!domain) { + const error = new Error('Missing DEFAULT_DOMAIN or MAILBOX_ADDRESS domain for user creation.'); + error.status = 400; + error.details = { code: 'missing_domain' }; + throw error; + } + + let lastError = null; + + for (let attempt = 0; attempt < 3; attempt += 1) { + const payload = generateRandomUserPayload(); + payload.userPrincipalName = `${payload.mailNickname}@${domain}`; + + try { + const user = await graphRequest('/users', undefined, { + method: 'POST', + json: { + accountEnabled: true, + displayName: payload.displayName, + mailNickname: payload.mailNickname, + userPrincipalName: payload.userPrincipalName, + passwordProfile: { + forceChangePasswordNextSignIn: true, + password: payload.password, + }, + }, + }); + + return { + id: user.id, + displayName: user.displayName || payload.displayName, + userPrincipalName: user.userPrincipalName || payload.userPrincipalName, + mailNickname: payload.mailNickname, + temporaryPassword: payload.password, + }; + } catch (error) { + lastError = error; + if (error.status !== 409 && error.status !== 400) { + throw error; + } + } + } + + throw lastError || new Error('Failed to create random user.'); +} + function sendGraphError(response, error) { const graphMessage = error.details?.error?.message; const graphCode = error.details?.error?.code; @@ -452,6 +556,49 @@ app.get('/api/users/:userId/latest-email', async (request, response) => { } }); +app.get('/api/users/:userId/latest-email/body', async (request, response) => { + try { + const user = await getUserById(request.params.userId); + + if (!user.hasMailboxAddress) { + response.status(400).json({ + error: 'This account does not expose a mailbox address in Microsoft Graph.', + code: 'mailbox_not_available', + }); + return; + } + + const message = await getLatestEmail(user.id); + + if (!message) { + response.json({ user, message: null }); + return; + } + + response.json({ + user, + message: { + id: message.id, + subject: message.subject, + receivedDateTime: message.receivedDateTime, + from: message.from, + body: message.body, + }, + }); + } catch (error) { + sendGraphError(response, error); + } +}); + +app.post('/api/users/random', async (_request, response) => { + try { + const user = await createRandomUser(); + response.status(201).json({ user }); + } catch (error) { + sendGraphError(response, error); + } +}); + app.listen(Number(PORT), () => { console.log(`Office 365 mail console listening on http://localhost:${PORT}`); console.log(`Access password protection: ${authEnabled ? 'enabled' : 'disabled'}`);