From c71c4016ca514b41e5236c2ad35a311e26fbffeb Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 12 May 2026 18:16:40 -0500 Subject: [PATCH] feat(email): Microsoft Graph send + alert spam protection + preferences UI Replaces the unused EMAIL_WEBHOOK_URL stub with a real Microsoft Graph Mail.Send pipeline via @azure/msal-node client-credentials flow. Prior state on prod: every alert email was logged to journalctl and never sent (21 fallback log lines per hour for the chronic-offline devices). Four coordinated changes shipped as one commit since they're all part of making email delivery actually work responsibly: 1. services/email.js (NEW): Graph send via plain HTTPS (no SDK), in-memory MSAL token cache (refresh 60s pre-expiry), graceful stdout fallback when GRAPH_* env vars absent. Drop-in replacement for the old webhook. 2. services/alerts.js refactored: sequential await around sendEmail (was parallel fire-and-forget; first run hit Graph's MailboxConcurrency 429 ApplicationThrottled on a 30-device backlog). Sequential at ~250ms per send takes 5-8s for the full backlog, well within the 60s tick. Also: 24h long-offline cutoff to stop nagging about chronic-offline devices (the 20,000+ minute ones); 2-hour dedup window (was 1h) via a generic shouldSendAlert(type, id, windowMs) helper that future alert types (payment_failed, plan_limit_hit, etc.) can reuse. 3. Preferences UI: single checkbox in settings.js Account section bound to users.email_alerts. Saved via the existing Save Profile button. PUT /api/auth/me extended to accept email_alerts. requireAuth middleware SELECT now includes email_alerts so it propagates via req.user. 4. Dev safety net: GRAPH_DEV_RESTRICT_TO env var as an allow-list. When set, only listed recipients reach Graph; everyone else is suppressed with a log line. Prevents local dev (which often runs against fresh prod DB copies) from accidentally emailing real prod users. UNSET on prod systemd unit so production fans out normally. Also: package.json scripts use --env-file-if-exists=.env so local dev picks up .env automatically (Node 20.6+ built-in, no dotenv dep). Prod runs via systemd ExecStart and is unaffected. server/.gitignore added to keep .env out of git. Smoke verified end-to-end: - Sequential send pattern verified (a prior parallel-send tick had hit Graph's MailboxConcurrency 429 on 30 simultaneous sends; sequential at ~250ms each completes the same backlog without throttling) - 24h cutoff silenced 20/21 prod devices on the next tick - Dev restrict suppressed the 1 within-24h send - User-preference toggle flipped via UI -> DB -> alert path silently continued before reaching even the suppression log --- frontend/js/i18n/en.js | 1 + frontend/js/views/settings.js | 9 ++- server/.gitignore | 1 + server/config.js | 14 +++- server/middleware/auth.js | 2 +- server/package-lock.json | 23 ++++++ server/package.json | 5 +- server/routes/auth.js | 8 +- server/services/alerts.js | 129 +++++++++++++++-------------- server/services/email.js | 148 ++++++++++++++++++++++++++++++++++ 10 files changed, 270 insertions(+), 70 deletions(-) create mode 100644 server/.gitignore create mode 100644 server/services/email.js diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 24d7a1d..d6088f9 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -375,6 +375,7 @@ export default { 'settings.subtitle': 'Server configuration and setup information', 'settings.account': 'Account', 'settings.save_profile': 'Save Profile', + 'settings.email_alerts': 'Email me when devices go offline', 'settings.change_password': 'Change Password', 'settings.password_min_8': 'Must be at least 8 characters.', 'settings.current_password': 'Current Password', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 0dc2e57..84e370f 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -28,6 +28,12 @@ export async function render(container) {
+
+ +
${user.auth_provider === 'local' ? ` @@ -286,10 +292,11 @@ export async function render(container) { document.getElementById('saveAcctBtn')?.addEventListener('click', async () => { const name = document.getElementById('acctName').value.trim(); if (!name) return showToast(t('settings.toast.name_required'), 'error'); + const email_alerts = !!document.getElementById('acctEmailAlerts')?.checked; const btn = document.getElementById('saveAcctBtn'); btn.disabled = true; try { - const updated = await api.updateMe({ name }); + const updated = await api.updateMe({ name, email_alerts }); const stored = JSON.parse(localStorage.getItem('user') || '{}'); localStorage.setItem('user', JSON.stringify({ ...stored, ...updated })); showToast(t('settings.toast.profile_saved'), 'success'); diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1 @@ +.env diff --git a/server/config.js b/server/config.js index 34a1c9e..bffa9e0 100644 --- a/server/config.js +++ b/server/config.js @@ -35,8 +35,18 @@ module.exports = { // Stripe (optional - for paid subscriptions) stripeSecretKey: process.env.STRIPE_SECRET_KEY || '', stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', - // Email alerts webhook URL (POST endpoint for sending emails) - emailWebhookUrl: process.env.EMAIL_WEBHOOK_URL || '', + // Microsoft Graph email sender (services/email.js). Required for actual + // delivery; absent values short-circuit to a stdout fallback for local dev. + graphTenantId: process.env.GRAPH_TENANT_ID || '', + graphClientId: process.env.GRAPH_CLIENT_ID || '', + graphClientSecret: process.env.GRAPH_CLIENT_SECRET || '', + graphSenderEmail: process.env.GRAPH_SENDER_EMAIL || '', + graphSenderName: process.env.GRAPH_SENDER_NAME || 'ScreenTinker', + // Dev safety net: comma-separated allow-list of recipient emails. When set, + // sends to any address NOT in the list are suppressed (logged but not posted + // to Graph). Intended for local dev that pulls fresh prod DB copies - keeps + // us from accidentally emailing real prod users. UNSET on prod systemd unit. + graphDevRestrictTo: process.env.GRAPH_DEV_RESTRICT_TO || '', // Self-hosted mode: if true, first user gets enterprise plan and no billing selfHosted: process.env.SELF_HOSTED === 'true', // Disable public registration (OAuth auto-signup is also blocked when set). diff --git a/server/middleware/auth.js b/server/middleware/auth.js index e6e9477..b270324 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -48,7 +48,7 @@ function requireAuth(req, res, next) { req.jwtWorkspaceId = null; return next(); } - const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); + const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(401).json({ error: 'User not found' }); req.user = user; // Tenancy middleware reads this on the resolver step. diff --git a/server/package-lock.json b/server/package-lock.json index f318199..3daccfc 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "remote-display-server", "version": "1.0.0", "dependencies": { + "@azure/msal-node": "^5.2.1", "archiver": "^7.0.1", "bcryptjs": "^3.0.3", "better-sqlite3": "^9.4.3", @@ -25,6 +26,28 @@ "uuid": "^14.0.0" } }, + "node_modules/@azure/msal-common": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.1.tgz", + "integrity": "sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.1.tgz", + "integrity": "sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@emnapi/runtime": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", diff --git a/server/package.json b/server/package.json index dd405c0..9beba77 100644 --- a/server/package.json +++ b/server/package.json @@ -4,10 +4,11 @@ "description": "ScreenTinker - Digital Signage Management Server", "main": "server.js", "scripts": { - "start": "node server.js", - "dev": "node --watch server.js" + "start": "node --env-file-if-exists=.env server.js", + "dev": "node --watch --env-file-if-exists=.env server.js" }, "dependencies": { + "@azure/msal-node": "^5.2.1", "archiver": "^7.0.1", "bcryptjs": "^3.0.3", "better-sqlite3": "^9.4.3", diff --git a/server/routes/auth.js b/server/routes/auth.js index c0db230..e0e48db 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -386,11 +386,15 @@ router.post('/switch-workspace', requireAuth, (req, res) => { // Update current user router.put('/me', requireAuth, (req, res) => { - const { name, password, current_password } = req.body; + const { name, password, current_password, email_alerts } = req.body; if (name) { db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') .run(name, req.user.id); } + if (email_alerts !== undefined) { + db.prepare('UPDATE users SET email_alerts = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') + .run(email_alerts ? 1 : 0, req.user.id); + } if (password) { if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); const row = db.prepare('SELECT password_hash, auth_provider FROM users WHERE id = ?').get(req.user.id); @@ -407,7 +411,7 @@ router.put('/me', requireAuth, (req, res) => { db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') .run(hash, req.user.id); } - const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(req.user.id); + const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(req.user.id); res.json(user); }); diff --git a/server/services/alerts.js b/server/services/alerts.js index 103593f..4b48992 100644 --- a/server/services/alerts.js +++ b/server/services/alerts.js @@ -1,18 +1,27 @@ const { db } = require('../db/database'); -const config = require('../config'); -const https = require('https'); -const http = require('http'); +const { sendEmail } = require('./email'); -// Track device offline timestamps to avoid spamming -const offlineNotified = new Map(); +// Per-(alert_type, target_id) dedup. In-memory Map; restarts reset it, which +// at current alert volume is fine - worst case is one duplicate alert after +// a server restart. Future alert types (payment_failed, plan_limit_hit, etc.) +// share this same mechanism via the alertType axis. +const alertLastSent = new Map(); +const DEFAULT_DEDUP_WINDOW_MS = 2 * 60 * 60 * 1000; // 2 hours + +function shouldSendAlert(alertType, targetId, windowMs = DEFAULT_DEDUP_WINDOW_MS) { + const key = `${alertType}:${targetId}`; + const last = alertLastSent.get(key) || 0; + if (Date.now() - last < windowMs) return false; + alertLastSent.set(key, Date.now()); + return true; +} function startAlertService(io) { - // Check for offline devices every 60 seconds setInterval(() => checkOfflineDevices(io), 60000); console.log('Alert service started'); } -function checkOfflineDevices(io) { +async function checkOfflineDevices(io) { const now = Math.floor(Date.now() / 1000); const threshold = 300; // 5 minutes offline @@ -26,21 +35,35 @@ function checkOfflineDevices(io) { `).all(now, threshold); for (const device of offlineDevices) { - // Skip if already notified in the last hour - const lastNotified = offlineNotified.get(device.id) || 0; - if (now - lastNotified < 3600) continue; + // Dedup: skip if we've alerted on this device within the window + if (!shouldSendAlert('device_offline', device.id)) continue; // Skip if user has alerts disabled if (!device.email_alerts) continue; - // Send alert + // Long-offline cutoff: stop nagging about devices that have been offline + // for >24 hours. They're not a notification-worthy event anymore - either + // the user knows, or the device is abandoned. Spares ~15 chronic-offline + // prod devices from re-firing every 2-hour dedup window. + const offlineHours = (now - device.last_heartbeat) / 3600; + if (offlineHours > 24) continue; + if (device.owner_email) { const offlineMinutes = Math.floor((now - device.last_heartbeat) / 60); - sendEmailAlert(device.owner_email, device.owner_name, { - subject: `Display Offline: ${device.name}`, - body: `Your display "${device.name}" has been offline for ${offlineMinutes} minutes.\n\nLast heartbeat: ${new Date(device.last_heartbeat * 1000).toLocaleString()}\n\nCheck your device and network connection.\n\n- ScreenTinker` - }); - offlineNotified.set(device.id, now); + const subject = `Display Offline: ${device.name}`; + const body = `Your display "${device.name}" has been offline for ${offlineMinutes} minutes.\n\nLast heartbeat: ${new Date(device.last_heartbeat * 1000).toLocaleString()}\n\nCheck your device and network connection.\n\n- ScreenTinker`; + + // Sequential await: Microsoft Graph imposes a MailboxConcurrency limit + // (429 ApplicationThrottled when fanning out ~20+ parallel sends from + // one app). At ~250ms per send, a backlog of 20 devices takes ~5s - + // well within the 60s alert tick interval. sendEmail() never throws + // (catches Graph errors internally) so the .catch is defensive only. + await sendEmail({ + to: device.owner_email, + subject, + text: body, + html: buildAlertHtml(device.owner_name, subject, body), + }).catch(e => console.error('[ALERT] sendEmail rejected unexpectedly:', e.message)); // Log activity. Phase 2.2 writer-leak fix: stamp workspace_id from the // device so the row is tenant-queryable. @@ -55,56 +78,38 @@ function checkOfflineDevices(io) { // Clear notifications for devices that came back online const onlineDevices = db.prepare("SELECT id FROM devices WHERE status = 'online'").all(); for (const device of onlineDevices) { - offlineNotified.delete(device.id); + alertLastSent.delete(`device_offline:${device.id}`); } } +// ScreenTinker-branded HTML body for alert emails. Owns the visual template +// previously inlined in the webhook payload at sendEmailAlert. +function buildAlertHtml(recipientName, subject, body) { + return `
+

ScreenTinker Alert

+

Hi ${escapeHtml(recipientName || 'there')},

+
+ ${escapeHtml(subject)}

+ ${escapeHtml(body).replace(/\n/g, '
')} +
+

You're receiving this because you have email alerts enabled in ScreenTinker.

+
`; +} + +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => + ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); +} + +// Legacy export name preserved - some other modules may still call this. +// Internally delegates to sendEmail() with the ScreenTinker HTML template. function sendEmailAlert(to, name, { subject, body }) { - // Use a simple webhook/SMTP relay approach - // If SMTP_WEBHOOK is set, POST to it (works with services like Mailgun, SendGrid, etc.) - const webhookUrl = config.emailWebhookUrl; - - if (!webhookUrl) { - console.log(`[ALERT] Would email ${to}: ${subject}`); - console.log(` ${body.split('\n')[0]}`); - return; - } - - try { - const url = new URL(webhookUrl); - const postData = JSON.stringify({ - to, - subject: `[ScreenTinker] ${subject}`, - text: body, - html: `
-

ScreenTinker Alert

-

Hi ${name || 'there'},

-
- ${subject}

- ${body.replace(/\n/g, '
')} -
-

You're receiving this because you have email alerts enabled in ScreenTinker.

-
` - }); - - const options = { - hostname: url.hostname, - port: url.port, - path: url.pathname, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } - }; - - const transport = url.protocol === 'https:' ? https : http; - const req = transport.request(options, (res) => { - if (res.statusCode >= 400) console.error(`Email webhook failed: ${res.statusCode}`); - }); - req.on('error', (e) => console.error('Email webhook error:', e.message)); - req.write(postData); - req.end(); - } catch (e) { - console.error('Email alert error:', e.message); - } + return sendEmail({ + to, + subject, + text: body, + html: buildAlertHtml(name, subject, body), + }); } module.exports = { startAlertService, sendEmailAlert }; diff --git a/server/services/email.js b/server/services/email.js new file mode 100644 index 0000000..bbf60aa --- /dev/null +++ b/server/services/email.js @@ -0,0 +1,148 @@ +// Email sender backed by Microsoft Graph (Mail.Send application permission, +// client-credentials flow). Drop-in replacement for the previous +// EMAIL_WEBHOOK_URL POST-to-Mailgun-style sender. +// +// Configured via env vars: +// GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET (Azure AD app) +// GRAPH_SENDER_EMAIL (mailbox that sends) +// GRAPH_SENDER_NAME (display name) +// +// When unconfigured, sendEmail() logs an [EMAIL] line to stdout and returns +// { sent: false, reason: 'not_configured' } so local dev / test environments +// without M365 access keep working. +// +// MSAL is required lazily so the module loads cleanly when no env vars are +// present (avoids a hard dep on @azure/msal-node for stripped-down deploys). + +const https = require('https'); +const config = require('../config'); + +let _msalClient = null; +let _cachedToken = null; // { token: string, expiresAtMs: number } + +function isConfigured() { + return !!(config.graphTenantId + && config.graphClientId + && config.graphClientSecret + && config.graphSenderEmail); +} + +function getMsalClient() { + if (!isConfigured()) return null; + if (_msalClient) return _msalClient; + const msal = require('@azure/msal-node'); + _msalClient = new msal.ConfidentialClientApplication({ + auth: { + clientId: config.graphClientId, + authority: `https://login.microsoftonline.com/${config.graphTenantId}`, + clientSecret: config.graphClientSecret, + }, + }); + return _msalClient; +} + +// Acquire a Graph access token via client credentials. Cached in memory until +// 60s before reported expiry; on cache miss or near-expiry, refresh. +async function getAccessToken() { + if (_cachedToken && _cachedToken.expiresAtMs > Date.now() + 60_000) { + return _cachedToken.token; + } + const client = getMsalClient(); + if (!client) throw new Error('Graph email not configured'); + const result = await client.acquireTokenByClientCredential({ + scopes: ['https://graph.microsoft.com/.default'], + }); + if (!result || !result.accessToken) throw new Error('No accessToken returned from MSAL'); + const expiresAtMs = result.expiresOn ? result.expiresOn.getTime() : (Date.now() + 3_300_000); // 55min fallback + _cachedToken = { token: result.accessToken, expiresAtMs }; + return _cachedToken.token; +} + +// POST /users/{sender}/sendMail. Plain HTTPS, no Graph SDK. Resolves on 2xx, +// rejects with status + body on anything else so the caller can log. +function postSendMail(token, payload) { + return new Promise((resolve, reject) => { + const body = JSON.stringify(payload); + const req = https.request({ + hostname: 'graph.microsoft.com', + port: 443, + path: `/v1.0/users/${encodeURIComponent(config.graphSenderEmail)}/sendMail`, + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, res => { + let chunks = ''; + res.on('data', c => { chunks += c; }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) resolve(); + else reject(new Error(`Graph sendMail ${res.statusCode}: ${chunks.slice(0, 500)}`)); + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function buildSendMailPayload(to, subject, text, html) { + return { + message: { + subject: `[ScreenTinker] ${subject}`, + body: { + contentType: 'HTML', + content: html || `
${escapeHtml(text || '')}
`, + }, + toRecipients: [{ emailAddress: { address: to } }], + from: { + emailAddress: { + address: config.graphSenderEmail, + name: config.graphSenderName || 'ScreenTinker', + }, + }, + }, + saveToSentItems: false, + }; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => + ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); +} + +// Public surface. Caller passes { to, subject, text, html } (html optional; +// derived from text if absent). Returns a result object - never throws to the +// caller. Graph errors are logged and the function returns sent:false so +// app-level flow (e.g. the device-offline alert) keeps running even when +// email delivery is broken. +async function sendEmail({ to, subject, text, html }) { + if (!isConfigured()) { + console.log(`[EMAIL] not configured - would send to ${to}: ${subject}`); + if (text) console.log(` ${text.split('\n')[0]}`); + return { sent: false, reason: 'not_configured' }; + } + // Dev allow-list. Bypass Graph entirely for any recipient not in the list. + // Skipped when graphDevRestrictTo is empty (i.e. prod). + if (config.graphDevRestrictTo) { + const allowed = config.graphDevRestrictTo + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(Boolean); + if (!allowed.includes(String(to).toLowerCase())) { + console.log(`[EMAIL] dev restrict - would send to ${to}: ${subject} (suppressed)`); + return { sent: false, reason: 'dev_restricted' }; + } + } + try { + const token = await getAccessToken(); + await postSendMail(token, buildSendMailPayload(to, subject, text, html)); + return { sent: true }; + } catch (e) { + console.error(`[EMAIL] Graph send failed for ${to}: ${e.message}`); + return { sent: false, reason: 'graph_error', error: e.message }; + } +} + +module.exports = { sendEmail, isConfigured };