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'}`); });