// --------------------------------------------------------------------------- // Fetches unread mail via Microsoft Graph and writes outlook.json. // Runs every minute via cron. Uses (and caches) the token created by // outlook-auth.js; refreshes it when expired and persists rotated // refresh tokens. // --------------------------------------------------------------------------- const fs = require("fs"); const path = require("path"); const https = require("https"); const { URL } = require("url"); const OUT_FILE = process.env.OUT_FILE || "/usr/share/nginx/html/outlook.json"; const CLIENT_ID = (process.env.OUTLOOK_CLIENT_ID || "").trim(); const TENANT = (process.env.OUTLOOK_TENANT || "organizations").trim(); const SCOPE = "https://graph.microsoft.com/Mail.Read offline_access"; const TOP = parseInt(process.env.OUTLOOK_TOP || "5", 10); function writeOut(payload) { fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); } function tokenFilePath() { const candidates = [ process.env.OUTLOOK_TOKEN_FILE, "/opt/dashboard/tokens/outlook.json", path.join(__dirname, "tokens", "outlook.json"), ].filter(Boolean); for (const p of candidates) { try { if (fs.existsSync(path.dirname(p))) return p; } catch { // ignore } } return null; } function request(url, options = {}) { return new Promise((resolve, reject) => { const u = new URL(url); const req = https.request( u, { method: options.method || "GET", headers: options.headers || {}, timeout: options.timeout || 15000, }, (res) => { let body = ""; res.setEncoding("utf8"); res.on("data", (chunk) => (body += chunk)); res.on("end", () => resolve({ status: res.statusCode, body })); } ); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); }); if (options.body) req.write(options.body); req.end(); }); } async function fetchJson(url, headers = {}) { const res = await request(url, { headers }); if (res.status < 200 || res.status >= 300) { const detail = (() => { try { return JSON.parse(res.body)?.error?.message || res.body; } catch { return res.body; } })(); throw new Error(`HTTP ${res.status}: ${String(detail).slice(0, 200)}`); } return JSON.parse(res.body); } async function getAccessToken(tokenFile) { const token = JSON.parse(fs.readFileSync(tokenFile, "utf8")); // Reuse the cached access token while it is valid (with 60 s slack). if (token.access_token && token.expires_on && token.expires_on > Date.now() + 60000) { return token.access_token; } const res = await request( `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: CLIENT_ID, grant_type: "refresh_token", refresh_token: token.refresh_token, scope: SCOPE, }).toString(), } ); const data = JSON.parse(res.body); if (res.status !== 200 || !data.access_token) { throw new Error(`Token refresh failed (${data.error || res.status}) — re-run outlook-auth.js`); } // Persist the (possibly rotated) refresh token + cached access token. fs.writeFileSync( tokenFile, JSON.stringify( { refresh_token: data.refresh_token || token.refresh_token, access_token: data.access_token, expires_on: Date.now() + (data.expires_in || 3600) * 1000, }, null, 2 ) ); return data.access_token; } async function main() { // Not configured: report that so the widget can show a helpful hint. if (!CLIENT_ID) { writeOut({ updatedAt: new Date().toISOString(), enabled: false, unreadCount: 0, messages: [] }); return; } const tokenFile = tokenFilePath(); if (!tokenFile || !fs.existsSync(tokenFile)) { writeOut({ updatedAt: new Date().toISOString(), enabled: true, error: "Not connected — run outlook-auth.js (see README)", unreadCount: 0, messages: [], }); return; } try { const accessToken = await getAccessToken(tokenFile); const auth = { Authorization: `Bearer ${accessToken}` }; const [me, folder, messages] = await Promise.all([ fetchJson("https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", auth), fetchJson("https://graph.microsoft.com/v1.0/me/mailFolders/inbox?$select=unreadItemCount", auth), fetchJson( `https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages` + `?$filter=${encodeURIComponent("isRead eq false")}` + `&$orderby=${encodeURIComponent("receivedDateTime desc")}` + `&$top=${TOP}&$select=subject,from,receivedDateTime`, auth ), ]); writeOut({ updatedAt: new Date().toISOString(), enabled: true, account: me.mail || me.userPrincipalName || "", unreadCount: folder.unreadItemCount ?? 0, messages: (messages.value || []).map((m) => ({ from: m.from?.emailAddress?.name || m.from?.emailAddress?.address || "unknown", subject: m.subject || "(no subject)", received: m.receivedDateTime, })), error: null, }); } catch (e) { writeOut({ updatedAt: new Date().toISOString(), enabled: true, error: e.message, unreadCount: 0, messages: [], }); } } main().catch((err) => { console.error(err); process.exit(1); });