Add Outlook unread-mail widget with device-code login
- outlook-auth.js: one-time device-code flow against Microsoft login; stores the refresh token in the outlook-tokens compose volume - update-outlook.js: per-minute cron fetching the unread count and the latest unread messages via Microsoft Graph; caches access tokens and persists rotated refresh tokens - Widget is hidden until the mailbox is actually authorized, so it stays out of sight while (admin) consent is pending - New external card for Outlook (Office 365) with the official icon - compose: pass OUTLOOK_CLIENT_ID/OUTLOOK_TENANT/GITEA_TOKEN from .env - README: Entra ID app registration steps and the tenant-ID hint for directories not resolvable via the organizations endpoint
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user