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.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',
|
||||
|
|
|
|||
|
|
@ -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
1
server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
23
server/package-lock.json
generated
23
server/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
// 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 ${name || 'there'},</p>
|
||||
<p>Hi ${escapeHtml(recipientName || 'there')},</p>
|
||||
<div style="background:#f1f5f9;padding:16px;border-radius:8px;margin:16px 0">
|
||||
<strong>${subject}</strong><br><br>
|
||||
${body.replace(/\n/g, '<br>')}
|
||||
<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>`
|
||||
});
|
||||
|
||||
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);
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
text: body,
|
||||
html: buildAlertHtml(name, subject, body),
|
||||
});
|
||||
}
|
||||
|
||||
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