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_ID=your-client-id
|
||||||
CLIENT_SECRET=your-client-secret
|
CLIENT_SECRET=your-client-secret
|
||||||
MAILBOX_ADDRESS=
|
MAILBOX_ADDRESS=
|
||||||
|
DEFAULT_DOMAIN=
|
||||||
ACCESS_PASSWORD=change-this-password
|
ACCESS_PASSWORD=change-this-password
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -12,6 +12,7 @@
|
|||||||
- 已授予并管理员同意以下 Microsoft Graph Application Permissions:
|
- 已授予并管理员同意以下 Microsoft Graph Application Permissions:
|
||||||
- `Mail.Read`
|
- `Mail.Read`
|
||||||
- `User.Read.All`
|
- `User.Read.All`
|
||||||
|
- `User.ReadWrite.All`
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ TENANT_ID=your-tenant-id
|
|||||||
CLIENT_ID=your-client-id
|
CLIENT_ID=your-client-id
|
||||||
CLIENT_SECRET=your-client-secret
|
CLIENT_SECRET=your-client-secret
|
||||||
MAILBOX_ADDRESS=
|
MAILBOX_ADDRESS=
|
||||||
|
DEFAULT_DOMAIN=
|
||||||
ACCESS_PASSWORD=change-this-password
|
ACCESS_PASSWORD=change-this-password
|
||||||
PORT=3000
|
PORT=3000
|
||||||
```
|
```
|
||||||
@@ -30,6 +32,7 @@ PORT=3000
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `MAILBOX_ADDRESS` 为可选默认选中账号。页面首次加载完成后,如果能在租户中找到这个邮箱地址,会自动展示它的最新邮件。
|
- `MAILBOX_ADDRESS` 为可选默认选中账号。页面首次加载完成后,如果能在租户中找到这个邮箱地址,会自动展示它的最新邮件。
|
||||||
|
- `DEFAULT_DOMAIN` 为创建随机用户时使用的域名。未填写时,程序会尝试从 `MAILBOX_ADDRESS` 提取域名。
|
||||||
- `ACCESS_PASSWORD` 为访问控制台所需的口令。设置后,用户必须先输入该密码才能打开页面和访问 API。
|
- `ACCESS_PASSWORD` 为访问控制台所需的口令。设置后,用户必须先输入该密码才能打开页面和访问 API。
|
||||||
- 读取租户账号和邮件实际依赖的是 `TENANT_ID`、`CLIENT_ID`、`CLIENT_SECRET`。
|
- 读取租户账号和邮件实际依赖的是 `TENANT_ID`、`CLIENT_ID`、`CLIENT_SECRET`。
|
||||||
|
|
||||||
@@ -42,13 +45,15 @@ PORT=3000
|
|||||||
| `CLIENT_SECRET` | 是 | 应用客户端密钥,用于应用身份认证 | Azure AD「证书和密码」 |
|
| `CLIENT_SECRET` | 是 | 应用客户端密钥,用于应用身份认证 | Azure AD「证书和密码」 |
|
||||||
| `Mail.Read` | 是 | 读取邮箱内容所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
| `Mail.Read` | 是 | 读取邮箱内容所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
||||||
| `User.Read.All` | 是 | 列出租户成员账号所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
| `User.Read.All` | 是 | 列出租户成员账号所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
||||||
|
| `User.ReadWrite.All` | 是 | 创建随机用户所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
||||||
| `ACCESS_PASSWORD` | 可选 | 控制台访问密码,设置后访问页面前必须先登录 | 自定义 |
|
| `ACCESS_PASSWORD` | 可选 | 控制台访问密码,设置后访问页面前必须先登录 | 自定义 |
|
||||||
|
|
||||||
补充说明:
|
补充说明:
|
||||||
|
|
||||||
- 这两个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。
|
- 这三个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。
|
||||||
- 本项目采用 `client_credentials` 模式,不需要配置重定向 URI。
|
- 本项目采用 `client_credentials` 模式,不需要配置重定向 URI。
|
||||||
- 如果只配置 `Mail.Read`,页面可以读邮件,但无法列出所有账号;如果只配置 `User.Read.All`,页面可以列出账号,但无法读取邮件正文。
|
- 如果只配置 `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`
|
||||||
- 返回该账号收件箱最新一封邮件
|
- 返回该账号收件箱最新一封邮件
|
||||||
|
- `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_ID,
|
||||||
CLIENT_SECRET,
|
CLIENT_SECRET,
|
||||||
MAILBOX_ADDRESS = '',
|
MAILBOX_ADDRESS = '',
|
||||||
|
DEFAULT_DOMAIN = '',
|
||||||
ACCESS_PASSWORD = '',
|
ACCESS_PASSWORD = '',
|
||||||
PORT = '3000',
|
PORT = '3000',
|
||||||
} = process.env;
|
} = process.env;
|
||||||
@@ -46,6 +47,7 @@ const tokenCache = {
|
|||||||
accessToken: null,
|
accessToken: null,
|
||||||
expiresAt: 0,
|
expiresAt: 0,
|
||||||
};
|
};
|
||||||
|
let cachedCreateUserDomain = DEFAULT_DOMAIN || '';
|
||||||
|
|
||||||
function timingSafeStringEqual(left, right) {
|
function timingSafeStringEqual(left, right) {
|
||||||
const leftBuffer = Buffer.from(left);
|
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 = [
|
const args = [
|
||||||
'-sS',
|
'-sS',
|
||||||
'-L',
|
'-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);
|
args.push(url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -247,6 +256,8 @@ async function graphRequest(pathname, query, init = {}) {
|
|||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
...(init.headers || {}),
|
...(init.headers || {}),
|
||||||
},
|
},
|
||||||
|
json: init.json ?? null,
|
||||||
|
body: init.body ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status < 200 || response.status >= 300) {
|
if (response.status < 200 || response.status >= 300) {
|
||||||
@@ -259,6 +270,41 @@ async function graphRequest(pathname, query, init = {}) {
|
|||||||
return response.body;
|
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() {
|
async function listUsers() {
|
||||||
const users = [];
|
const users = [];
|
||||||
let nextUrl = buildGraphUrl('/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) {
|
function sendGraphError(response, error) {
|
||||||
const graphMessage = error.details?.error?.message;
|
const graphMessage = error.details?.error?.message;
|
||||||
const graphCode = error.details?.error?.code;
|
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), () => {
|
app.listen(Number(PORT), () => {
|
||||||
console.log(`Office 365 mail console listening on http://localhost:${PORT}`);
|
console.log(`Office 365 mail console listening on http://localhost:${PORT}`);
|
||||||
console.log(`Access password protection: ${authEnabled ? 'enabled' : 'disabled'}`);
|
console.log(`Access password protection: ${authEnabled ? 'enabled' : 'disabled'}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user