add user creation and mail body APIs

This commit is contained in:
2026-04-02 21:27:16 +08:00
parent 91609d15aa
commit df9dd80bb3
3 changed files with 159 additions and 2 deletions

View File

@@ -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

View File

@@ -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`
- 自动创建一个随机用户名的新用户,并返回用户主体与临时密码
## 注意事项

149
server.js
View File

@@ -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'}`);