add office 365 mail console

This commit is contained in:
2026-04-02 21:16:23 +08:00
commit 91609d15aa
14 changed files with 2287 additions and 0 deletions

458
server.js Normal file
View File

@@ -0,0 +1,458 @@
const path = require('path');
const { execFile } = require('child_process');
const crypto = require('crypto');
const { promisify } = require('util');
const dotenv = require('dotenv');
const express = require('express');
dotenv.config();
const {
TENANT_ID,
CLIENT_ID,
CLIENT_SECRET,
MAILBOX_ADDRESS = '',
ACCESS_PASSWORD = '',
PORT = '3000',
} = process.env;
const requiredEnv = {
TENANT_ID,
CLIENT_ID,
CLIENT_SECRET,
};
const missingEnv = Object.entries(requiredEnv)
.filter(([, value]) => !value)
.map(([key]) => key);
if (missingEnv.length > 0) {
console.error(`Missing required environment variables: ${missingEnv.join(', ')}`);
process.exit(1);
}
const app = express();
const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
const APP_PUBLIC_DIR = path.join(__dirname, 'public');
const AUTH_COOKIE_NAME = 'office365_mail_auth';
const AUTH_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const execFileAsync = promisify(execFile);
const authEnabled = Boolean(ACCESS_PASSWORD);
const authCookieSecret = authEnabled
? crypto.createHash('sha256').update(`${TENANT_ID}:${CLIENT_ID}:${CLIENT_SECRET}:${ACCESS_PASSWORD}`).digest()
: null;
const tokenCache = {
accessToken: null,
expiresAt: 0,
};
function timingSafeStringEqual(left, right) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function parseCookies(cookieHeader = '') {
return cookieHeader
.split(';')
.map((segment) => segment.trim())
.filter(Boolean)
.reduce((cookies, segment) => {
const separatorIndex = segment.indexOf('=');
if (separatorIndex === -1) {
return cookies;
}
const key = segment.slice(0, separatorIndex).trim();
const value = segment.slice(separatorIndex + 1).trim();
cookies[key] = decodeURIComponent(value);
return cookies;
}, {});
}
function signAuthCookiePayload(payload) {
return crypto.createHmac('sha256', authCookieSecret).update(payload).digest('hex');
}
function createAuthCookieValue() {
const expiresAt = String(Date.now() + AUTH_SESSION_TTL_MS);
return `${expiresAt}.${signAuthCookiePayload(expiresAt)}`;
}
function setAuthCookie(response) {
response.setHeader(
'Set-Cookie',
`${AUTH_COOKIE_NAME}=${encodeURIComponent(createAuthCookieValue())}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${AUTH_SESSION_TTL_MS / 1000}`,
);
}
function clearAuthCookie(response) {
response.setHeader(
'Set-Cookie',
`${AUTH_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
);
}
function isAuthenticatedRequest(request) {
if (!authEnabled) {
return true;
}
const token = parseCookies(request.headers.cookie)[AUTH_COOKIE_NAME];
if (!token) {
return false;
}
const [expiresAt, signature] = token.split('.');
if (!expiresAt || !signature || !/^\d+$/.test(expiresAt)) {
return false;
}
if (Date.now() > Number(expiresAt)) {
return false;
}
return timingSafeStringEqual(signature, signAuthCookiePayload(expiresAt));
}
function isPublicAuthPath(pathname) {
return pathname === '/login' || pathname === '/login.css' || pathname === '/auth/login';
}
function buildGraphUrl(pathname, query = {}) {
const url = pathname.startsWith('https://')
? new URL(pathname)
: new URL(`${GRAPH_BASE_URL}${pathname}`);
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
});
return url.toString();
}
function parseResponseBody(text) {
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function curlJsonRequest(url, { method = 'GET', headers = {}, form = null } = {}) {
const args = [
'-sS',
'-L',
'--connect-timeout',
'15',
'--max-time',
'90',
'-w',
'\n%{http_code}',
];
if (method !== 'GET') {
args.push('-X', method);
}
Object.entries(headers).forEach(([key, value]) => {
args.push('-H', `${key}: ${value}`);
});
if (form) {
Object.entries(form).forEach(([key, value]) => {
args.push('--data-urlencode', `${key}=${value}`);
});
}
args.push(url);
try {
const { stdout } = await execFileAsync('curl', args, {
maxBuffer: 20 * 1024 * 1024,
});
const separatorIndex = stdout.lastIndexOf('\n');
const bodyText = separatorIndex === -1 ? stdout : stdout.slice(0, separatorIndex);
const statusText = separatorIndex === -1 ? '' : stdout.slice(separatorIndex + 1).trim();
return {
status: Number(statusText),
body: parseResponseBody(bodyText),
};
} catch (error) {
const requestError = new Error('Microsoft request failed.');
requestError.status = 502;
requestError.details = error.stderr || error.message || null;
throw requestError;
}
}
async function getAccessToken() {
const now = Date.now();
if (tokenCache.accessToken && now < tokenCache.expiresAt - 60_000) {
return tokenCache.accessToken;
}
const response = await curlJsonRequest(
`https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
form: {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials',
},
},
);
if (response.status < 200 || response.status >= 300) {
const error = new Error('Failed to acquire Microsoft Graph access token.');
error.status = response.status;
error.details = response.body;
throw error;
}
const payload = response.body;
tokenCache.accessToken = payload.access_token;
tokenCache.expiresAt = now + (payload.expires_in || 3600) * 1000;
return tokenCache.accessToken;
}
async function graphRequest(pathname, query, init = {}) {
const accessToken = await getAccessToken();
const response = await curlJsonRequest(buildGraphUrl(pathname, query), {
method: init.method || 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
...(init.headers || {}),
},
});
if (response.status < 200 || response.status >= 300) {
const error = new Error('Microsoft Graph request failed.');
error.status = response.status;
error.details = response.body;
throw error;
}
return response.body;
}
async function listUsers() {
const users = [];
let nextUrl = buildGraphUrl('/users', {
'$top': '999',
'$select': 'id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled,userType',
});
while (nextUrl) {
const page = await graphRequest(nextUrl);
users.push(...(page.value || []));
nextUrl = page['@odata.nextLink'] || null;
}
return users
.filter((user) => user.accountEnabled !== false && user.userType !== 'Guest')
.map((user) => ({
id: user.id,
displayName: user.displayName || user.userPrincipalName || user.mail || 'Unnamed user',
mail: user.mail || '',
userPrincipalName: user.userPrincipalName || '',
jobTitle: user.jobTitle || '',
department: user.department || '',
mailboxAddress: user.mail || user.userPrincipalName || '',
hasMailboxAddress: Boolean(user.mail || user.userPrincipalName),
}))
.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'));
}
async function getUserById(userId) {
const user = await graphRequest(`/users/${encodeURIComponent(userId)}`, {
'$select': 'id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled,userType',
});
return {
id: user.id,
displayName: user.displayName || user.userPrincipalName || user.mail || 'Unnamed user',
mail: user.mail || '',
userPrincipalName: user.userPrincipalName || '',
jobTitle: user.jobTitle || '',
department: user.department || '',
mailboxAddress: user.mail || user.userPrincipalName || '',
hasMailboxAddress: Boolean(user.mail || user.userPrincipalName),
};
}
function formatRecipients(recipients = []) {
return recipients.map((recipient) => ({
name: recipient.emailAddress?.name || '',
address: recipient.emailAddress?.address || '',
}));
}
async function getLatestEmail(userId) {
const payload = await graphRequest(`/users/${encodeURIComponent(userId)}/mailFolders/Inbox/messages`, {
'$top': '1',
'$orderby': 'receivedDateTime desc',
'$select': 'id,subject,receivedDateTime,from,webLink,hasAttachments,isRead,toRecipients,ccRecipients,internetMessageId',
});
const message = payload.value?.[0];
if (!message) {
return null;
}
const details = await graphRequest(
`/users/${encodeURIComponent(userId)}/messages/${encodeURIComponent(message.id)}`,
{
'$select': 'body,bodyPreview',
},
{
headers: {
Prefer: 'outlook.body-content-type="html"',
},
},
);
return {
id: message.id,
subject: message.subject || '(No subject)',
receivedDateTime: message.receivedDateTime,
from: {
name: message.from?.emailAddress?.name || '',
address: message.from?.emailAddress?.address || '',
},
toRecipients: formatRecipients(message.toRecipients),
ccRecipients: formatRecipients(message.ccRecipients),
body: {
contentType: details.body?.contentType || 'text',
content: details.body?.content || '',
},
bodyPreview: details.bodyPreview || '',
webLink: message.webLink || '',
hasAttachments: Boolean(message.hasAttachments),
isRead: Boolean(message.isRead),
internetMessageId: message.internetMessageId || '',
};
}
function sendGraphError(response, error) {
const graphMessage = error.details?.error?.message;
const graphCode = error.details?.error?.code;
response.status(error.status || 500).json({
error: graphMessage || error.message || 'Unexpected server error.',
code: graphCode || 'internal_error',
details: error.details || null,
});
}
app.use(express.urlencoded({ extended: false }));
app.use((request, response, next) => {
if (!authEnabled || isPublicAuthPath(request.path) || isAuthenticatedRequest(request)) {
next();
return;
}
if (request.path.startsWith('/api/')) {
response.status(401).json({
error: 'Authentication required.',
code: 'auth_required',
});
return;
}
response.redirect('/login');
});
app.get('/login', (request, response) => {
if (!authEnabled || isAuthenticatedRequest(request)) {
response.redirect('/');
return;
}
response.sendFile(path.join(APP_PUBLIC_DIR, 'login.html'));
});
app.post('/auth/login', (request, response) => {
if (!authEnabled) {
response.redirect('/');
return;
}
const submittedPassword = typeof request.body.password === 'string' ? request.body.password : '';
if (timingSafeStringEqual(submittedPassword, ACCESS_PASSWORD)) {
setAuthCookie(response);
response.redirect('/');
return;
}
clearAuthCookie(response);
response.redirect('/login?error=1');
});
app.get('/', (_request, response) => {
response.sendFile(path.join(APP_PUBLIC_DIR, 'index.html'));
});
app.use(express.static(APP_PUBLIC_DIR, { index: false }));
app.get('/api/users', async (_request, response) => {
try {
const users = await listUsers();
response.json({
users,
defaultMailboxAddress: MAILBOX_ADDRESS,
});
} catch (error) {
sendGraphError(response, error);
}
});
app.get('/api/users/:userId/latest-email', 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);
response.json({ user, message });
} 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'}`);
});