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 };