459 lines
12 KiB
JavaScript
459 lines
12 KiB
JavaScript
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'}`);
|
|
});
|