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
This commit is contained in:
ScreenTinker 2026-05-12 18:16:40 -05:00
parent f115cb454f
commit c71c4016ca
10 changed files with 270 additions and 70 deletions

View file

@ -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',

View file

@ -28,6 +28,12 @@ export async function render(container) {
<div class="form-group"><label>${t('auth.email')}</label><input type="email" class="input" value="${esc(user.email || '')}" disabled></div>
<div class="form-group"><label>${t('auth.name')}</label><input type="text" id="acctName" class="input" value="${esc(user.name || '')}"></div>
</div>
<div class="form-group" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="acctEmailAlerts" ${user.email_alerts ? 'checked' : ''}>
<span>${t('settings.email_alerts')}</span>
</label>
</div>
<button class="btn btn-secondary btn-sm" id="saveAcctBtn">${t('settings.save_profile')}</button>
${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');

1
server/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

View file

@ -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).

View file

@ -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.

View file

@ -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",

View file

@ -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",

View file

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

View file

@ -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 `<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">
<h2 style="color:#3b82f6">ScreenTinker Alert</h2>
<p>Hi ${escapeHtml(recipientName || 'there')},</p>
<div style="background:#f1f5f9;padding:16px;border-radius:8px;margin:16px 0">
<strong>${escapeHtml(subject)}</strong><br><br>
${escapeHtml(body).replace(/\n/g, '<br>')}
</div>
<p style="color:#94a3b8;font-size:12px">You're receiving this because you have email alerts enabled in ScreenTinker.</p>
</div>`;
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">
<h2 style="color:#3b82f6">ScreenTinker Alert</h2>
<p>Hi ${name || 'there'},</p>
<div style="background:#f1f5f9;padding:16px;border-radius:8px;margin:16px 0">
<strong>${subject}</strong><br><br>
${body.replace(/\n/g, '<br>')}
</div>
<p style="color:#94a3b8;font-size:12px">You're receiving this because you have email alerts enabled in ScreenTinker.</p>
</div>`
});
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 };

148
server/services/email.js Normal file
View file

@ -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 || `<pre style="font-family:sans-serif">${escapeHtml(text || '')}</pre>`,
},
toRecipients: [{ emailAddress: { address: to } }],
from: {
emailAddress: {
address: config.graphSenderEmail,
name: config.graphSenderName || 'ScreenTinker',
},
},
},
saveToSentItems: false,
};
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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 };