add office 365 mail console
This commit is contained in:
458
server.js
Normal file
458
server.js
Normal 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'}`);
|
||||
});
|
||||
Reference in New Issue
Block a user