mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
f115cb454f
commit
c71c4016ca
|
|
@ -375,6 +375,7 @@ export default {
|
||||||
'settings.subtitle': 'Server configuration and setup information',
|
'settings.subtitle': 'Server configuration and setup information',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.save_profile': 'Save Profile',
|
'settings.save_profile': 'Save Profile',
|
||||||
|
'settings.email_alerts': 'Email me when devices go offline',
|
||||||
'settings.change_password': 'Change Password',
|
'settings.change_password': 'Change Password',
|
||||||
'settings.password_min_8': 'Must be at least 8 characters.',
|
'settings.password_min_8': 'Must be at least 8 characters.',
|
||||||
'settings.current_password': 'Current Password',
|
'settings.current_password': 'Current Password',
|
||||||
|
|
|
||||||
|
|
@ -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.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 class="form-group"><label>${t('auth.name')}</label><input type="text" id="acctName" class="input" value="${esc(user.name || '')}"></div>
|
||||||
</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>
|
<button class="btn btn-secondary btn-sm" id="saveAcctBtn">${t('settings.save_profile')}</button>
|
||||||
|
|
||||||
${user.auth_provider === 'local' ? `
|
${user.auth_provider === 'local' ? `
|
||||||
|
|
@ -286,10 +292,11 @@ export async function render(container) {
|
||||||
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
|
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
|
||||||
const name = document.getElementById('acctName').value.trim();
|
const name = document.getElementById('acctName').value.trim();
|
||||||
if (!name) return showToast(t('settings.toast.name_required'), 'error');
|
if (!name) return showToast(t('settings.toast.name_required'), 'error');
|
||||||
|
const email_alerts = !!document.getElementById('acctEmailAlerts')?.checked;
|
||||||
const btn = document.getElementById('saveAcctBtn');
|
const btn = document.getElementById('saveAcctBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateMe({ name });
|
const updated = await api.updateMe({ name, email_alerts });
|
||||||
const stored = JSON.parse(localStorage.getItem('user') || '{}');
|
const stored = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
localStorage.setItem('user', JSON.stringify({ ...stored, ...updated }));
|
localStorage.setItem('user', JSON.stringify({ ...stored, ...updated }));
|
||||||
showToast(t('settings.toast.profile_saved'), 'success');
|
showToast(t('settings.toast.profile_saved'), 'success');
|
||||||
|
|
|
||||||
1
server/.gitignore
vendored
Normal file
1
server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.env
|
||||||
|
|
@ -35,8 +35,18 @@ module.exports = {
|
||||||
// Stripe (optional - for paid subscriptions)
|
// Stripe (optional - for paid subscriptions)
|
||||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||||
// Email alerts webhook URL (POST endpoint for sending emails)
|
// Microsoft Graph email sender (services/email.js). Required for actual
|
||||||
emailWebhookUrl: process.env.EMAIL_WEBHOOK_URL || '',
|
// 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
|
// Self-hosted mode: if true, first user gets enterprise plan and no billing
|
||||||
selfHosted: process.env.SELF_HOSTED === 'true',
|
selfHosted: process.env.SELF_HOSTED === 'true',
|
||||||
// Disable public registration (OAuth auto-signup is also blocked when set).
|
// Disable public registration (OAuth auto-signup is also blocked when set).
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ function requireAuth(req, res, next) {
|
||||||
req.jwtWorkspaceId = null;
|
req.jwtWorkspaceId = null;
|
||||||
return next();
|
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' });
|
if (!user) return res.status(401).json({ error: 'User not found' });
|
||||||
req.user = user;
|
req.user = user;
|
||||||
// Tenancy middleware reads this on the resolver step.
|
// Tenancy middleware reads this on the resolver step.
|
||||||
|
|
|
||||||
23
server/package-lock.json
generated
23
server/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "remote-display-server",
|
"name": "remote-display-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-node": "^5.2.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
|
|
@ -25,6 +26,28 @@
|
||||||
"uuid": "^14.0.0"
|
"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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
"description": "ScreenTinker - Digital Signage Management Server",
|
"description": "ScreenTinker - Digital Signage Management Server",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node --env-file-if-exists=.env server.js",
|
||||||
"dev": "node --watch server.js"
|
"dev": "node --watch --env-file-if-exists=.env server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-node": "^5.2.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
|
|
|
||||||
|
|
@ -386,11 +386,15 @@ router.post('/switch-workspace', requireAuth, (req, res) => {
|
||||||
|
|
||||||
// Update current user
|
// Update current user
|
||||||
router.put('/me', requireAuth, (req, res) => {
|
router.put('/me', requireAuth, (req, res) => {
|
||||||
const { name, password, current_password } = req.body;
|
const { name, password, current_password, email_alerts } = req.body;
|
||||||
if (name) {
|
if (name) {
|
||||||
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||||
.run(name, req.user.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) {
|
||||||
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
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);
|
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 = ?')
|
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||||
.run(hash, req.user.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);
|
res.json(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
const config = require('../config');
|
const { sendEmail } = require('./email');
|
||||||
const https = require('https');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
// Track device offline timestamps to avoid spamming
|
// Per-(alert_type, target_id) dedup. In-memory Map; restarts reset it, which
|
||||||
const offlineNotified = new Map();
|
// 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) {
|
function startAlertService(io) {
|
||||||
// Check for offline devices every 60 seconds
|
|
||||||
setInterval(() => checkOfflineDevices(io), 60000);
|
setInterval(() => checkOfflineDevices(io), 60000);
|
||||||
console.log('Alert service started');
|
console.log('Alert service started');
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkOfflineDevices(io) {
|
async function checkOfflineDevices(io) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const threshold = 300; // 5 minutes offline
|
const threshold = 300; // 5 minutes offline
|
||||||
|
|
||||||
|
|
@ -26,21 +35,35 @@ function checkOfflineDevices(io) {
|
||||||
`).all(now, threshold);
|
`).all(now, threshold);
|
||||||
|
|
||||||
for (const device of offlineDevices) {
|
for (const device of offlineDevices) {
|
||||||
// Skip if already notified in the last hour
|
// Dedup: skip if we've alerted on this device within the window
|
||||||
const lastNotified = offlineNotified.get(device.id) || 0;
|
if (!shouldSendAlert('device_offline', device.id)) continue;
|
||||||
if (now - lastNotified < 3600) continue;
|
|
||||||
|
|
||||||
// Skip if user has alerts disabled
|
// Skip if user has alerts disabled
|
||||||
if (!device.email_alerts) continue;
|
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) {
|
if (device.owner_email) {
|
||||||
const offlineMinutes = Math.floor((now - device.last_heartbeat) / 60);
|
const offlineMinutes = Math.floor((now - device.last_heartbeat) / 60);
|
||||||
sendEmailAlert(device.owner_email, device.owner_name, {
|
const subject = `Display Offline: ${device.name}`;
|
||||||
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`;
|
||||||
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
|
||||||
offlineNotified.set(device.id, now);
|
// (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
|
// Log activity. Phase 2.2 writer-leak fix: stamp workspace_id from the
|
||||||
// device so the row is tenant-queryable.
|
// device so the row is tenant-queryable.
|
||||||
|
|
@ -55,56 +78,38 @@ function checkOfflineDevices(io) {
|
||||||
// Clear notifications for devices that came back online
|
// Clear notifications for devices that came back online
|
||||||
const onlineDevices = db.prepare("SELECT id FROM devices WHERE status = 'online'").all();
|
const onlineDevices = db.prepare("SELECT id FROM devices WHERE status = 'online'").all();
|
||||||
for (const device of onlineDevices) {
|
for (const device of onlineDevices) {
|
||||||
offlineNotified.delete(device.id);
|
alertLastSent.delete(`device_offline:${device.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendEmailAlert(to, name, { subject, body }) {
|
// ScreenTinker-branded HTML body for alert emails. Owns the visual template
|
||||||
// Use a simple webhook/SMTP relay approach
|
// previously inlined in the webhook payload at sendEmailAlert.
|
||||||
// If SMTP_WEBHOOK is set, POST to it (works with services like Mailgun, SendGrid, etc.)
|
function buildAlertHtml(recipientName, subject, body) {
|
||||||
const webhookUrl = config.emailWebhookUrl;
|
return `<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">
|
||||||
|
|
||||||
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>
|
<h2 style="color:#3b82f6">ScreenTinker Alert</h2>
|
||||||
<p>Hi ${name || 'there'},</p>
|
<p>Hi ${escapeHtml(recipientName || 'there')},</p>
|
||||||
<div style="background:#f1f5f9;padding:16px;border-radius:8px;margin:16px 0">
|
<div style="background:#f1f5f9;padding:16px;border-radius:8px;margin:16px 0">
|
||||||
<strong>${subject}</strong><br><br>
|
<strong>${escapeHtml(subject)}</strong><br><br>
|
||||||
${body.replace(/\n/g, '<br>')}
|
${escapeHtml(body).replace(/\n/g, '<br>')}
|
||||||
</div>
|
</div>
|
||||||
<p style="color:#94a3b8;font-size:12px">You're receiving this because you have email alerts enabled in ScreenTinker.</p>
|
<p style="color:#94a3b8;font-size:12px">You're receiving this because you have email alerts enabled in ScreenTinker.</p>
|
||||||
</div>`
|
</div>`;
|
||||||
});
|
}
|
||||||
|
|
||||||
const options = {
|
function escapeHtml(s) {
|
||||||
hostname: url.hostname,
|
return String(s ?? '').replace(/[&<>"']/g, c =>
|
||||||
port: url.port,
|
({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
path: url.pathname,
|
}
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }
|
|
||||||
};
|
|
||||||
|
|
||||||
const transport = url.protocol === 'https:' ? https : http;
|
// Legacy export name preserved - some other modules may still call this.
|
||||||
const req = transport.request(options, (res) => {
|
// Internally delegates to sendEmail() with the ScreenTinker HTML template.
|
||||||
if (res.statusCode >= 400) console.error(`Email webhook failed: ${res.statusCode}`);
|
function sendEmailAlert(to, name, { subject, body }) {
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text: body,
|
||||||
|
html: buildAlertHtml(name, subject, body),
|
||||||
});
|
});
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startAlertService, sendEmailAlert };
|
module.exports = { startAlertService, sendEmailAlert };
|
||||||
|
|
|
||||||
148
server/services/email.js
Normal file
148
server/services/email.js
Normal 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 =>
|
||||||
|
({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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 };
|
||||||
Loading…
Reference in a new issue