add user creation and mail body APIs
This commit is contained in:
@@ -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
|
||||
|
||||
11
README.md
11
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`
|
||||
- 自动创建一个随机用户名的新用户,并返回用户主体与临时密码
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
149
server.js
149
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'}`);
|
||||
|
||||
Reference in New Issue
Block a user