From dfe246b7ca502cb3d06880337dcbc323ec2ba17f Mon Sep 17 00:00:00 2001 From: fegger Date: Fri, 4 Sep 2026 17:38:17 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + Dockerfile | 4 +- README.md | 34 ++++++++ api/outlook-auth.js | 191 ++++++++++++++++++++++++++++++++++++++++++ api/update-outlook.js | 185 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 12 ++- docker-entrypoint.sh | 4 + icons/outlook.ico | Bin 0 -> 7886 bytes index.html | 15 +++- nginx.conf | 5 ++ script.js | 65 ++++++++++++++ style.css | 54 ++++++++++++ 12 files changed, 564 insertions(+), 6 deletions(-) create mode 100644 api/outlook-auth.js create mode 100644 api/update-outlook.js create mode 100644 icons/outlook.ico diff --git a/.gitignore b/.gitignore index f713b30..5733309 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules api/node_modules +api/tokens *.tgz *.log .env diff --git a/Dockerfile b/Dockerfile index 31c01a0..8e41082 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,8 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf # Backend scripts (node_modules included so the build works offline). COPY api /opt/dashboard -# Cron refreshes the data feed every 15 minutes and health checks every minute. -RUN printf '*/15 * * * * cd /opt/dashboard && node update-data.js\n* * * * * cd /opt/dashboard && node update-health.js\n' > /etc/crontabs/root +# Cron: data feed every 15 min, health checks + Outlook unread every minute. +RUN printf '*/15 * * * * cd /opt/dashboard && node update-data.js\n* * * * * cd /opt/dashboard && node update-health.js\n* * * * * cd /opt/dashboard && node update-outlook.js\n' > /etc/crontabs/root # Entrypoint: run updater once, start API proxy + cron, then nginx. COPY docker-entrypoint.sh /docker-entrypoint.sh diff --git a/README.md b/README.md index 3762eee..394983c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,40 @@ automatically. Non-secret settings go directly in `docker-compose.yml`: | `GITEA_URL` / `GITLAB_URL` / `NETBIRD_URL` / `OPENWEBUI_URL` | Override the default service URLs | | `GPU_STATS_URL` | GPU stats upstream (default: the `gpu-stats` compose service) | | `OLLAMA_URL` | Ollama instance for loaded-model stats (default: `http://100.103.83.12:11435`) | +| `OUTLOOK_CLIENT_ID` | Entra ID app client ID for the unread-mail widget (**set in `.env`**) | +| `OUTLOOK_TENANT` | Azure tenant (default: `organizations`; set to your Directory (tenant) ID if the generic endpoint returns AADSTS50059) | + +### Outlook unread mail + +The Outlook widget (and the unread count) uses the Microsoft Graph API with a +one-time device login. Setup: + +1. **Register an app** in Microsoft Entra ID (portal.azure.com → Entra ID → + App registrations → New registration): any name (e.g. "Homelab Dashboard"), + account type "Accounts in this organizational directory only", no redirect URI. +2. In the app: **Authentication → Allow public client flows → Yes**, then + **API permissions → Add → Microsoft Graph → Delegated → Mail.Read**. +3. Copy the **Application (client) ID** — from App registrations → your app → + Overview. Beware: this is *not* the "Directory (tenant) ID" and not the + "Object ID". A valid-format but wrong GUID produces AADSTS50059 + ("No tenant-identifying information found"). Put it in `.env` as + `OUTLOOK_CLIENT_ID=`. +4. Set `OUTLOOK_TENANT` in `.env` to your **Directory (tenant) ID**. Some + directories are not resolvable through the generic `organizations` + endpoint (also AADSTS50059 with a valid client ID) — the tenant-specific + endpoint always works. +4. `docker compose up -d --build` +5. Log in once: + ```bash + docker exec -it homelab-dashboard node /opt/dashboard/outlook-auth.js + ``` + Open the printed URL, enter the code and sign in with your work account. + The refresh token is persisted in the `outlook-tokens` volume. + +`api/update-outlook.js` then refreshes unread mail every minute (caching the +access token, and re-running `outlook-auth.js` is only needed if login is +revoked). If your tenant requires admin consent, ask your admin to grant it +for `Mail.Read`. ### GPU stats service diff --git a/api/outlook-auth.js b/api/outlook-auth.js new file mode 100644 index 0000000..d264ea8 --- /dev/null +++ b/api/outlook-auth.js @@ -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); +}); \ No newline at end of file diff --git a/api/update-outlook.js b/api/update-outlook.js new file mode 100644 index 0000000..3cee7c5 --- /dev/null +++ b/api/update-outlook.js @@ -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); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d603b17..ac3a1ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,9 +13,13 @@ services: - TZ=Europe/Vienna # Secrets live in .env (gitignored) — compose reads it automatically. - OPENWEBUI_TOKEN=${OPENWEBUI_TOKEN:-} + - GITEA_TOKEN=${GITEA_TOKEN:-} + # Outlook unread-mail widget (see README for the Entra ID app registration + # and the one-time device login). + - OUTLOOK_CLIENT_ID=${OUTLOOK_CLIENT_ID:-} + - OUTLOOK_TENANT=${OUTLOOK_TENANT:-organizations} # Optional API tokens for live stats / git integration. # Uncomment and fill in to enable private git stats. - # - GITEA_TOKEN= # - GITLAB_TOKEN= # - NETBIRD_TOKEN= # GPU stats upstream; defaults to the gpu-stats service below. @@ -25,6 +29,9 @@ services: # - GPU_STATS_URL=http://gpu-stats:9101/stats # Ollama instance for the loaded-model stats in the GPU widget. # - OLLAMA_URL=http://100.103.83.12:11435 + volumes: + # Persists the Outlook OAuth refresh token across container updates. + - outlook-tokens:/opt/dashboard/tokens logging: driver: json-file options: @@ -53,3 +60,6 @@ services: options: max-size: "1m" max-file: "2" + +volumes: + outlook-tokens: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2079ea5..d4eea85 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -16,5 +16,9 @@ node /opt/dashboard/server.js & # First health-check sweep in the background; cron re-runs it every minute. ( cd /opt/dashboard && node update-health.js ) & +# Outlook unread mail (no-op until OUTLOOK_CLIENT_ID is set and the device +# login has been completed); cron re-runs it every minute. +( cd /opt/dashboard && node update-outlook.js ) & + # Start nginx in the foreground. exec nginx -g "daemon off;" \ No newline at end of file diff --git a/icons/outlook.ico b/icons/outlook.ico new file mode 100644 index 0000000000000000000000000000000000000000..1df4eec563ac8780c14df5fb50557847f549e458 GIT binary patch literal 7886 zcmeHMeN2^A7(Z~q55NWY@^M9BSHLjasx{Xitv_7N+MIL5N~_h>a((~mdMG7b= zOKBF(kChhAN~h&&nL1Ih+#!T;I!DU2lZ6E0WMrWk>gm4)nh~f=k$;;1@2mBN&zDJ5ayEi4$9M0`R*wXZU=4vetpV zdItu*n2b$%*E7N`iSn?ixzvX0Wodr+1C28bAGaw!9^B+VXAM7WTy?CB-}ibJ_AE=p z_C;~{qCC;h$10xAtGaZ4sBtccbh%pI_*g1-EJ?!V`zB(~L$L-C|G}~Z^vm_Ff$=S;ws1+n<}aT`8aK0?IrdN39Z^C#g*>6B5&pzFJI*6o8P zUN7tI^KPisC!(X`2Gen<`pz9J^!asoZXd+_SzXJxg{P4R{O7~ ztAWqsS(zILNq&0|+y7$sKEFBAe1q9P-1#f`rF8bGwxqSiNnK~^`lB_f_8BWapS!9q z;@Jo6YrU-R0g=WJcmIhrzG{z9_TOjc3BU7){mVJ}@?b{6?62a7!bbq#?;8jOliPe* zEKsvxeDh~JSmm_G1fyW_5Bb2%_9U~y)9ZQ8W;y%XEWcjx-^!bSNRn<}t8yf9$aJc>PM;WAkT6V_%UIomHtY8#l}JasBvq9{&1u$oKgYPcpq-!CeCV zZ{%Qq$ut~#(uv->6jL0vAIH1@T=L*T>n4MUr$lvXThh;K>bv?Gc)u_m&E;tr(B3)J zJ`~?mQe$k6z4h2%)lWEde`%|7VQ;}ye7)|n%~bsyGpjPQxt2i$Ymzhn<9|I+szEQTUE?>p-FiPzUB ztsm9j+G4D-`>)AIx%I3&HYhUH^FEJ~MmhGO6D) zMhw$)UDqFpGJUIm$`DN@sn;xIQ)E$+w*}C@Ql=8=rZ`OgLNfjog%XF!wG>utL}zsG zmgzow1zU?|^3!j+j5C=28L%y_;*S06GC^@nXKktx$DHz>NAgY3qrOp^@gF@AgVpyY z7;#Fk_J(WL-b}XYo;dK?t>)aet@3vKap4S^-blogjO!Q8NkD5wT7V9&r}lsU?m=fm zzCpzQ(%bCoU$W4FPuFAw#1`D2KnD&~`q~puiR;O}| zpmUD((^z@@9d&mbwxBt2{raN7w*&G$*ORR-4u4O>Fp_RRtNyI#aIpg|YqEW~EU$U$ z=74?Oi*LDp4i`=e=u3MnuU*dBHhLaxKjwav%l#@*@5kj(`-i#j8Q*TRm?f`sM7eT4 dE=f}*rAnfIX~zFF$cvnp+%gBw$a1rn{sVYA&V>K~ literal 0 HcmV?d00001 diff --git a/index.html b/index.html index 1f0ceae..bc12317 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Homelab Dashboard + GG36
-

Homelab

-

All your services, one place.

+

GG36

@@ -62,6 +61,16 @@
+ +
diff --git a/nginx.conf b/nginx.conf index 1001098..b76b410 100644 --- a/nginx.conf +++ b/nginx.conf @@ -18,6 +18,11 @@ server { add_header Cache-Control "no-cache" always; } + # Outlook unread mail; refreshed every minute by cron. + location = /outlook.json { + add_header Cache-Control "no-cache" always; + } + # Open WebUI chat proxy. location /api/chat { proxy_pass http://127.0.0.1:3001; diff --git a/script.js b/script.js index e068f96..6311b8d 100644 --- a/script.js +++ b/script.js @@ -38,6 +38,7 @@ const SERVICES = [ { section: "internal", name: "Netbird", url: "https://netbird.gg36.at", cat: "network", icon: '', liveStatus: "netbird" }, // ----- External ----- + { section: "external", name: "Outlook (Office 365)", url: "https://outlook.office.com/mail/", cat: "external", icon: '' }, { section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: '' }, { section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: '' }, ]; @@ -603,6 +604,68 @@ async function loadHealth() { } } +// --------------------------------------------------------------------------- +// Outlook unread mail (via /outlook.json, refreshed every minute server-side) +// --------------------------------------------------------------------------- + +function relTime(iso) { + const t = new Date(iso).getTime(); + if (!t) return ""; + const mins = Math.floor((Date.now() - t) / 60000); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +function renderOutlookWidget(o) { + const widget = document.getElementById("widget-outlook"); + const el = document.getElementById("outlook-mail"); + if (!widget || !el) return; + + // Show the widget only once the mailbox is actually authorized; hide it + // while not configured, waiting for (admin) consent or on auth errors. + const authorized = Boolean(o && o.enabled && !o.error); + widget.hidden = !authorized; + if (!authorized) return; + + const account = o.account ? `` : ""; + + const badge = `${o.unreadCount ?? 0} unread`; + const msgs = o.messages || []; + + if (msgs.length === 0) { + el.innerHTML = `${account}${badge}

No unread mail.

`; + return; + } + + el.innerHTML = + `${account}${badge}` + + msgs + .map( + (m) => ` +
+
+ ${escapeHtml(m.from || "unknown")} + ${escapeHtml(relTime(m.received))} +
+
${escapeHtml(m.subject || "(no subject)")}
+
` + ) + .join(""); +} + +async function loadOutlook() { + try { + const res = await fetch("outlook.json", { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + renderOutlookWidget(await res.json()); + } catch { + // keep the current content; the next poll will retry + } +} + // --------------------------------------------------------------------------- // Search / filter // --------------------------------------------------------------------------- @@ -664,3 +727,5 @@ loadOllamaStats(); setInterval(loadOllamaStats, 5000); // live Ollama loaded models every 5 s loadHealth(); setInterval(loadHealth, 30000); // service health badges every 30 s +loadOutlook(); +setInterval(loadOutlook, 60000); // unread mail every 60 s diff --git a/style.css b/style.css index ed2abc0..79d5cf2 100644 --- a/style.css +++ b/style.css @@ -1014,6 +1014,60 @@ main#app { font-weight: 700; } +/* outlook unread mail widget */ +.mail-account { + font-size: 11.5px; + color: var(--muted-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.git-badge.mail { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} + +.mail-row { + display: grid; + gap: 2px; + padding: 7px 9px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); +} + +.mail-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-size: 11.5px; + min-width: 0; +} + +.mail-from { + color: var(--accent-2); + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mail-time { + color: var(--muted-2); + flex: none; + font-variant-numeric: tabular-nums; +} + +.mail-subject { + font-size: 12.5px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* ---------- empty / footer ---------- */ /* ---------- responsive ---------- */