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:
2026-09-04 17:38:17 +02:00
parent c15cfc51a9
commit dfe246b7ca
12 changed files with 564 additions and 6 deletions
+191
View File
@@ -0,0 +1,191 @@
// ---------------------------------------------------------------------------
// One-time device-code login for the Outlook unread-mail widget.
//
// Usage:
// 1. Set OUTLOOK_CLIENT_ID (Entra ID app registration — see README)
// 2. docker exec -it homelab-dashboard node /opt/dashboard/outlook-auth.js
// (or locally: OUTLOOK_CLIENT_ID=... node api/outlook-auth.js)
// 3. Open the printed URL, enter the code, sign in with your work account
//
// Stores the refresh token in a token file used by update-outlook.js.
// ---------------------------------------------------------------------------
const fs = require("fs");
const path = require("path");
const https = require("https");
const { URL } = require("url");
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";
// Hints for the most common Entra ID app-registration mistakes.
const AUTH_HINTS = {
50059:
"The client ID does not match any registered application. Copy the " +
"'Application (client) ID' from App registrations > your app > Overview " +
"(NOT the 'Directory (tenant) ID' or 'Object ID'), check for stray " +
"whitespace/quotes, and verify the app exists in your directory. If the " +
"client ID is correct, your directory may not be resolvable via the " +
"generic 'organizations' endpoint — set OUTLOOK_TENANT to your " +
"Directory (tenant) ID.",
700038: "The client ID is not a valid identifier — check for typos or truncation.",
700016:
"Application not found in the tenant. The app registration may be in a " +
"different directory, or the client ID is wrong.",
7000215:
"The app is not configured as a public client. In the app registration: " +
"Authentication > Allow public client flows > Yes.",
};
function aadstsHint(body) {
try {
const codes = JSON.parse(body)?.error_codes || [];
for (const c of codes) {
if (AUTH_HINTS[c]) return `\nHint (AADSTS${c}): ${AUTH_HINTS[c]}`;
}
const m = /AADSTS(\d+)/.exec(String(body));
if (m && AUTH_HINTS[m[1]]) return `\nHint (AADSTS${m[1]}): ${AUTH_HINTS[m[1]]}`;
} catch {
// ignore
}
return "";
}
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
}
}
const fallback = path.join(__dirname, "tokens", "outlook.json");
fs.mkdirSync(path.dirname(fallback), { recursive: true });
return fallback;
}
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();
});
}
function form(data) {
return new URLSearchParams(data).toString();
}
async function main() {
if (!CLIENT_ID) {
console.error("OUTLOOK_CLIENT_ID is not set.");
console.error("Register an app in Entra ID (see README: Outlook unread mail) and put its client ID in .env.");
process.exit(1);
}
const file = tokenFilePath();
console.log(`Token file: ${file}\n`);
// 1. Request a device code
const dcRes = await request(
`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/devicecode`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form({ client_id: CLIENT_ID, scope: SCOPE }),
}
);
if (dcRes.status !== 200) {
console.error(`Device code request failed (${dcRes.status}):`, dcRes.body);
console.error(aadstsHint(dcRes.body));
process.exit(1);
}
const dc = JSON.parse(dcRes.body);
console.log("----------------------------------------------------------------");
console.log(` 1. Open: ${dc.verification_uri}`);
console.log(` 2. Code: ${dc.user_code}`);
console.log(` 3. Sign in with your work account and approve the consent prompt`);
console.log("----------------------------------------------------------------\n");
console.log("Waiting for login…");
// 2. Poll for the token
const interval = (dc.interval || 5) * 1000;
const deadline = Date.now() + (dc.expires_in || 900) * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, interval));
const res = await request(
`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form({
client_id: CLIENT_ID,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: dc.device_code,
}),
}
);
const data = JSON.parse(res.body);
if (res.status === 200 && data.access_token) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
JSON.stringify(
{
refresh_token: data.refresh_token,
access_token: data.access_token,
expires_on: Date.now() + (data.expires_in || 3600) * 1000,
},
null,
2
)
);
console.log("\nLogin successful — token stored. The unread-mail widget will pick it up within a minute.");
return;
}
if (data.error === "authorization_pending") continue;
if (data.error === "slow_down") {
await new Promise((r) => setTimeout(r, 5000));
continue;
}
console.error(`\nLogin failed: ${data.error}${data.error_description || ""}`);
process.exit(1);
}
console.error("\nDevice code expired — run the script again.");
process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+185
View File
@@ -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);
});