mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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
149 lines
5.4 KiB
JavaScript
149 lines
5.4 KiB
JavaScript
// 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 };
|