From cf4c71d7d0a518712ce22d1b4e6f3e9d3b070883 Mon Sep 17 00:00:00 2001 From: screentinker Date: Mon, 13 Jul 2026 15:56:22 -0500 Subject: [PATCH] feat(email): SMTP transport as an alternative to Microsoft Graph [#173] (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pluggable email transport so self-hosters without Azure/M365 can send mail through any standard SMTP server (Postfix, Gmail, Mailgun, SendGrid, corp relay). Graph stays the default; behavior is byte-for-byte unchanged when EMAIL_TRANSPORT is unset or "graph". - config: EMAIL_TRANSPORT ("graph"|"smtp", default graph) + SMTP_HOST/PORT/ SECURE/USER/PASSWORD/FROM. - services/email.js: branch by transport behind the SAME public sendEmail()/ isConfigured() surface. SMTP via nodemailer (lazy-required, like MSAL). Shared across both transports: the "[ScreenTinker] " subject prefix (unless rawSubject), the GRAPH_DEV_RESTRICT_TO allow-list, html-from-text derivation, and the never-throws contract (failures log + return sent:false). SMTP_SECURE true=implicit TLS(465)/false=STARTTLS(587). Auth optional (unauthenticated relay ok); SMTP_USER without SMTP_PASSWORD is flagged. SMTP_FROM parses "Name ". New emailConfigStatus() for startup diagnostics. - server.js: startup logs the transport and a LOUD error when the selected transport is partially configured (some fields set, others missing) or when EMAIL_TRANSPORT is invalid (falls back to graph). A fully-unset transport stays a silent stdout fallback (unchanged dev behavior). - nodemailer ^6.9.16 added as a production dep (bundled in the Docker image). - .env.example + README: SMTP config section, Gmail example, transport table. - test/email-transport.test.js: 15 tests — transport selection, config validation (missing/partial/invalid), SMTP message building (from/prefix/ fromName override/text alt), sendEmail routing (mocked nodemailer), rawSubject, dev-restrict on smtp, and the smtp_error never-throws path. 462/462 server tests pass. Boot verified for all four states (configured, misconfigured, invalid, default). Closes #173 Co-authored-by: Claude Opus 4.8 --- .env.example | 32 ++++- README.md | 47 ++++++- server/config.js | 14 +++ server/package-lock.json | 10 ++ server/package.json | 1 + server/server.js | 19 +++ server/services/email.js | 184 +++++++++++++++++++++------- server/test/email-transport.test.js | 175 ++++++++++++++++++++++++++ 8 files changed, 428 insertions(+), 54 deletions(-) create mode 100644 server/test/email-transport.test.js diff --git a/.env.example b/.env.example index ea53378..5b1dfb7 100644 --- a/.env.example +++ b/.env.example @@ -39,15 +39,35 @@ HIDE_BILLING=true # user base with our onboarding mail. Only the hosted instance sets this true. # HOSTED_INSTANCE=true -# --- Outbound email (Microsoft Graph, client-credentials flow) --- -# Required for ANY email (welcome, offline alerts, admin notify) to actually -# send. Leave blank and the app logs "[EMAIL] not configured" instead of sending. +# --- Outbound email --- +# Email transport: "graph" (default, Microsoft Graph) or "smtp" (any mail server). +# Required for ANY email (welcome, offline alerts, admin notify) to actually send. +# Leave the selected transport blank and the app logs "[EMAIL] not configured" +# instead of sending. A partially-configured transport logs a clear error at boot. +# EMAIL_TRANSPORT=graph + +# --- Email via Microsoft Graph (client-credentials flow; EMAIL_TRANSPORT=graph) --- # GRAPH_TENANT_ID= # GRAPH_CLIENT_ID= # GRAPH_CLIENT_SECRET= # GRAPH_SENDER_EMAIL=signage@example.com # GRAPH_SENDER_NAME=ScreenTinker -# Dev safety net: comma-separated allow-list of recipients. When set, mail to -# any address NOT in the list is suppressed (logged, not sent). Leave UNSET in -# production. Useful locally so test signups can't email real users. + +# --- Email via SMTP (alternative to Microsoft Graph; EMAIL_TRANSPORT=smtp) --- +# For self-hosters without Azure/M365: Postfix, Gmail, Mailgun, SendGrid, a corp +# relay, etc. SMTP_SECURE=true is implicit TLS on 465; false is STARTTLS on 587. +# SMTP_USER/SMTP_PASSWORD are optional (omit both for an unauthenticated relay); +# if SMTP_USER is set, SMTP_PASSWORD is required. SMTP_FROM accepts "Name ". +# EMAIL_TRANSPORT=smtp +# SMTP_HOST=mail.example.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=noreply@example.com +# SMTP_PASSWORD=your-smtp-password +# SMTP_FROM=ScreenTinker + +# Dev safety net (applies to BOTH transports): comma-separated allow-list of +# recipients. When set, mail to any address NOT in the list is suppressed +# (logged, not sent). Leave UNSET in production. Useful locally so test signups +# can't email real users. # GRAPH_DEV_RESTRICT_TO=me@example.com diff --git a/README.md b/README.md index b6e4bd6..98f6847 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Schema migrations run automatically the first time the server starts after a git - **Android / web players** → device-namespace WebSocket → server. Authenticated per-device with a long-lived device token. Each device joins a room keyed on its `device_id`. - **Admin dashboard** → dashboard-namespace WebSocket → server. Authenticated with the user's JWT. Each socket joins one room per accessible workspace so outbound events (device status, screenshots, playback progress) only reach dashboards that should see them. - **Admin REST** → `/api/*` HTTPS → Express → SQLite. Everything scoped by `workspace_id` from JWT `current_workspace_id` claim. -- **Email** → Microsoft Graph `sendMail` via client-credentials OAuth flow. In-memory token cache. Sequential send pattern through alert backlogs to respect Graph's per-app concurrency limits. +- **Email** → pluggable transport (`EMAIL_TRANSPORT`): Microsoft Graph `sendMail` via client-credentials OAuth (in-memory token cache) **or** SMTP via nodemailer. Sequential send pattern through alert backlogs to respect per-app concurrency limits. ## Supported Platforms @@ -190,9 +190,19 @@ Let users sign in with Microsoft/Azure AD. | `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID | | `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) | -#### Email Alerts (Microsoft Graph) +#### Email (Microsoft Graph or SMTP) -Send email notifications when devices go offline. Backed by Microsoft Graph Mail.Send via the client-credentials flow. +Email powers offline alerts, welcome/signup mail, admin notifications, and password reset. Two interchangeable transports are supported, selected by `EMAIL_TRANSPORT`: + +| Variable | Description | Default | +|----------|-------------|---------| +| `EMAIL_TRANSPORT` | `graph` (Microsoft Graph) or `smtp` (any mail server) | `graph` | + +Configure the variables for whichever transport you pick (below). If the selected transport is left blank, email is disabled and delivery is logged to stdout instead. If it is **partially** configured (some fields set, others missing), the server logs a clear `[EMAIL] … MISCONFIGURED — missing: …` error at startup. + +##### Option A — Microsoft Graph (`EMAIL_TRANSPORT=graph`, default) + +Microsoft Graph `Mail.Send` via the client-credentials flow. Best if you already run Microsoft 365 / Azure. | Variable | Description | |----------|-------------| @@ -211,13 +221,40 @@ Send email notifications when devices go offline. Backed by Microsoft Graph Mail 5. Capture the **Directory (tenant) ID** and **Application (client) ID** from the Overview page 6. Set the five env vars above in your deployment (systemd unit, `.env` file, etc.) -**Local dev fallback:** if any of `GRAPH_TENANT_ID`, `GRAPH_CLIENT_ID`, `GRAPH_CLIENT_SECRET`, or `GRAPH_SENDER_EMAIL` is unset, `sendEmail()` short-circuits and logs `[EMAIL] not configured - would send to ...` to stdout instead of calling Graph. The app keeps running normally; only delivery is suppressed. This means a minimal local-dev install with no M365 access works fine — email-triggering features (device-offline alerts, future invite emails) just won't deliver anything externally. +##### Option B — SMTP (`EMAIL_TRANSPORT=smtp`) + +Send via any standard mail server (Postfix, Gmail, Mailgun, SendGrid, a corporate relay, …) using [nodemailer](https://nodemailer.com). Ideal for self-hosters without an Azure/M365 setup. + +| Variable | Description | Default | +|----------|-------------|---------| +| `SMTP_HOST` | Mail server hostname (e.g. `mail.example.com`) | _(required)_ | +| `SMTP_PORT` | Port — `587` for STARTTLS, `465` for implicit TLS | _(required)_ | +| `SMTP_SECURE` | `true` = implicit TLS (465); `false` = STARTTLS (587) | `false` | +| `SMTP_USER` | Auth username. Omit (with `SMTP_PASSWORD`) for an unauthenticated relay | _(none)_ | +| `SMTP_PASSWORD` | Auth password. Required **if** `SMTP_USER` is set | _(none)_ | +| `SMTP_FROM` | From address — `Name ` or `addr@example.com` | _(required; falls back to `SMTP_USER`)_ | + +Example (Gmail app password): + +``` +EMAIL_TRANSPORT=smtp +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=you@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM=ScreenTinker +``` + +The Docker image bundles nodemailer, so no extra steps are needed for a self-hosted container — just set the `SMTP_*` vars in your `env_file` / compose `environment`. + +**Local dev fallback:** if the selected transport is unconfigured (e.g. no `GRAPH_*`, or no `SMTP_*`), `sendEmail()` short-circuits and logs `[EMAIL] not configured - would send to ...` to stdout instead of sending. The app keeps running normally; only delivery is suppressed. A minimal local-dev install with no mail access works fine — email-triggering features just won't deliver anything externally. **Dev safety allow-list:** | Variable | Description | |----------|-------------| -| `GRAPH_DEV_RESTRICT_TO` | Comma-separated allow-list of recipient emails. When set, sends to addresses **not** in the list are suppressed (logged but never posted to Graph). | +| `GRAPH_DEV_RESTRICT_TO` | Comma-separated allow-list of recipient emails (applies to **both** transports). When set, sends to addresses **not** in the list are suppressed (logged but never delivered). | Use this in local dev when running against a fresh production database clone to prevent accidental emails to real users. Leave it **unset in production** so emails flow to everyone normally. diff --git a/server/config.js b/server/config.js index 954ceef..3611fd1 100644 --- a/server/config.js +++ b/server/config.js @@ -96,6 +96,20 @@ module.exports = { // 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 || '', + // Email transport selector for services/email.js: "graph" (default, Microsoft + // Graph) or "smtp" (nodemailer). Lets self-hosters without an Azure/M365 setup + // use a standard mail server (Postfix, Gmail, Mailgun, SendGrid, corp relay). + emailTransport: process.env.EMAIL_TRANSPORT || 'graph', + // SMTP transport (used when EMAIL_TRANSPORT=smtp). SMTP_SECURE=true is implicit + // TLS on 465; false is STARTTLS on 587. User/password are optional so an + // unauthenticated localhost relay works; if SMTP_USER is set, SMTP_PASSWORD is + // required. SMTP_FROM is the envelope/display From ("Name " or "addr"). + smtpHost: process.env.SMTP_HOST || '', + smtpPort: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : 0, + smtpSecure: process.env.SMTP_SECURE === 'true', + smtpUser: process.env.SMTP_USER || '', + smtpPassword: process.env.SMTP_PASSWORD || '', + smtpFrom: process.env.SMTP_FROM || '', // Self-hosted mode: if true, first user gets enterprise plan and no billing selfHosted: process.env.SELF_HOSTED === 'true', // #116: opt-in UI gate. When true, hides the Subscription nav item + billing view diff --git a/server/package-lock.json b/server/package-lock.json index 66ae676..393d771 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,6 +19,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.16", "otplib": "^12.0.1", "qrcode": "^1.5.4", "sharp": "^0.33.2", @@ -2772,6 +2773,15 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/server/package.json b/server/package.json index 2a11db1..df6adb4 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.16", "otplib": "^12.0.1", "qrcode": "^1.5.4", "sharp": "^0.33.2", diff --git a/server/server.js b/server/server.js index 32c5062..7122de3 100644 --- a/server/server.js +++ b/server/server.js @@ -954,6 +954,25 @@ server.listen(listenPort, '0.0.0.0', () => { ║ Listening on all interfaces (0.0.0.0) ║ ╚══════════════════════════════════════════════════╝ `); + + // Email transport diagnostics — a partially-configured transport is a real + // misconfiguration (some fields set, others missing) and gets a loud line; + // a fully-unset transport just falls back to the stdout logger silently. + try { + const es = require('./services/email').emailConfigStatus(); + if (es.invalidTransport) { + console.error(`[EMAIL] EMAIL_TRANSPORT="${es.rawTransport}" is invalid — expected "graph" or "smtp". Falling back to graph.`); + } + if (es.partiallyConfigured) { + console.error(`[EMAIL] ${es.transport.toUpperCase()} transport selected but MISCONFIGURED — missing: ${es.missing.join(', ')}. Email delivery is DISABLED until these are set.`); + } else if (es.configured) { + console.log(`[EMAIL] transport: ${es.transport} (configured)`); + } else { + console.log(`[EMAIL] transport: ${es.transport} (not configured — emails log to stdout only)`); + } + } catch (e) { + console.error(`[EMAIL] config check failed: ${e.message}`); + } }); // If SSL is enabled, also start an HTTP server that redirects to HTTPS diff --git a/server/services/email.js b/server/services/email.js index bc365dc..8ece453 100644 --- a/server/services/email.js +++ b/server/services/email.js @@ -1,34 +1,81 @@ -// 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. +// Email sender with a pluggable transport: Microsoft Graph (default) or SMTP. // -// 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) +// Transport is chosen by EMAIL_TRANSPORT ("graph" | "smtp"; default "graph"). +// An unknown value falls back to "graph" and is flagged by emailConfigStatus(). // -// 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. +// graph — Microsoft Graph, client-credentials flow (no Graph SDK, plain HTTPS) +// GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, +// GRAPH_SENDER_EMAIL, GRAPH_SENDER_NAME +// smtp — any standard mail server via nodemailer +// SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD, SMTP_FROM // -// 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). +// When the selected transport is unconfigured, sendEmail() logs an [EMAIL] line +// to stdout and returns { sent:false, reason:'not_configured' } so local dev / +// test environments without mail access keep working. +// +// The heavy deps (@azure/msal-node for Graph, nodemailer for SMTP) are required +// lazily so a deploy that uses only one transport never needs the other, and the +// module loads cleanly when no email is configured at all. const https = require('https'); const config = require('../config'); -let _msalClient = null; -let _cachedToken = null; // { token: string, expiresAtMs: number } +const VALID_TRANSPORTS = ['graph', 'smtp']; +const RAW_TRANSPORT = (config.emailTransport || 'graph').toLowerCase(); +const TRANSPORT = VALID_TRANSPORTS.includes(RAW_TRANSPORT) ? RAW_TRANSPORT : 'graph'; -function isConfigured() { - return !!(config.graphTenantId - && config.graphClientId - && config.graphClientSecret - && config.graphSenderEmail); +let _msalClient = null; +let _cachedToken = null; // { token: string, expiresAtMs: number } +let _smtpTransporter = null; + +// ─────────────────────────── configuration ─────────────────────────── + +function graphMissing() { + const missing = []; + if (!config.graphTenantId) missing.push('GRAPH_TENANT_ID'); + if (!config.graphClientId) missing.push('GRAPH_CLIENT_ID'); + if (!config.graphClientSecret) missing.push('GRAPH_CLIENT_SECRET'); + if (!config.graphSenderEmail) missing.push('GRAPH_SENDER_EMAIL'); + return missing; } +// SMTP needs a server (host+port) and a From identity. Auth is optional so an +// unauthenticated localhost relay works; but a user without a password is a +// misconfiguration, so flag it. +function smtpMissing() { + const missing = []; + if (!config.smtpHost) missing.push('SMTP_HOST'); + if (!config.smtpPort) missing.push('SMTP_PORT'); + if (!smtpFromAddress()) missing.push('SMTP_FROM (or SMTP_USER)'); + if (config.smtpUser && !config.smtpPassword) missing.push('SMTP_PASSWORD'); + return missing; +} + +function isConfigured() { + return (TRANSPORT === 'smtp' ? smtpMissing() : graphMissing()).length === 0; +} + +// Startup diagnostics. Distinguishes three states so server.js can log the right +// thing: configured, intentionally-unconfigured (nothing set → silent stdout +// fallback), and partially-configured (some fields set but not all → real misconfig). +function emailConfigStatus() { + const missing = TRANSPORT === 'smtp' ? smtpMissing() : graphMissing(); + const anySet = TRANSPORT === 'smtp' + ? !!(config.smtpHost || config.smtpPort || config.smtpUser || config.smtpPassword || config.smtpFrom) + : !!(config.graphTenantId || config.graphClientId || config.graphClientSecret || config.graphSenderEmail); + return { + transport: TRANSPORT, + invalidTransport: !!config.emailTransport && !VALID_TRANSPORTS.includes(RAW_TRANSPORT), + rawTransport: config.emailTransport || '', + configured: missing.length === 0, + partiallyConfigured: anySet && missing.length > 0, + missing, + }; +} + +// ─────────────────────────── Microsoft Graph ─────────────────────────── + function getMsalClient() { - if (!isConfigured()) return null; if (_msalClient) return _msalClient; const msal = require('@azure/msal-node'); _msalClient = new msal.ConfidentialClientApplication({ @@ -48,7 +95,6 @@ async function getAccessToken() { 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'], }); @@ -87,18 +133,14 @@ function postSendMail(token, payload) { }); } -// rawSubject: when true, the subject is sent verbatim (no "[ScreenTinker] " -// prefix) — used by the signup emails which carry their own clean subjects. -// fromName: overrides the default GRAPH_SENDER_NAME display name (the From -// address is always graphSenderEmail, so replies still land in that mailbox). -function buildSendMailPayload(to, subject, text, html, fromName, rawSubject) { +// The From address is always graphSenderEmail (so replies land in that mailbox); +// fromName overrides only the display name. subject/html are already finalized +// by sendEmail (prefix applied, html derived from text) — this builder is pure. +function buildGraphPayload(to, subject, html, fromName) { return { message: { - subject: rawSubject ? subject : `[ScreenTinker] ${subject}`, - body: { - contentType: 'HTML', - content: html || `
${escapeHtml(text || '')}
`, - }, + subject, + body: { contentType: 'HTML', content: html }, toRecipients: [{ emailAddress: { address: to } }], from: { emailAddress: { @@ -111,24 +153,66 @@ function buildSendMailPayload(to, subject, text, html, fromName, rawSubject) { }; } +// ─────────────────────────── SMTP (nodemailer) ─────────────────────────── + +// Parse the bare address out of SMTP_FROM ("Name " or "a@b.com"); +// fall back to SMTP_USER when SMTP_FROM has no usable address. +function smtpFromAddress() { + const from = config.smtpFrom || ''; + const m = /<([^>]+)>/.exec(from); + if (m) return m[1].trim(); + if (from.includes('@')) return from.trim(); + return (config.smtpUser || '').trim(); +} + +function getSmtpTransporter() { + if (_smtpTransporter) return _smtpTransporter; + const nodemailer = require('nodemailer'); + const opts = { + host: config.smtpHost, + port: Number(config.smtpPort), + secure: !!config.smtpSecure, // true = implicit TLS (465); false = STARTTLS (587) + }; + if (config.smtpUser) opts.auth = { user: config.smtpUser, pass: config.smtpPassword }; + _smtpTransporter = nodemailer.createTransport(opts); + return _smtpTransporter; +} + +// Pure message builder (exported for tests). fromName overrides the display name +// while keeping the configured From address; otherwise SMTP_FROM is used verbatim. +function buildSmtpMessage(to, subject, text, html, fromName) { + const from = fromName + ? { name: fromName, address: smtpFromAddress() } + : (config.smtpFrom || smtpFromAddress()); + const msg = { from, to, subject, html }; + if (text) msg.text = text; // keep a plain-text alternative when the caller gave one + return msg; +} + +async function smtpSend(to, subject, text, html, fromName) { + await getSmtpTransporter().sendMail(buildSmtpMessage(to, subject, text, html, fromName)); +} + +// ─────────────────────────── public surface ─────────────────────────── + 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. +// Caller passes { to, subject, text, html } (html optional; derived from text if +// absent). rawSubject:true sends the subject verbatim (no "[ScreenTinker] " +// prefix). fromName overrides the display name. Returns a result object and never +// throws — delivery failures are logged and returned as sent:false so app flow +// (offline alerts, signup mail, etc.) keeps running even when email is broken. async function sendEmail({ to, subject, text, html, fromName, rawSubject }) { 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). + // Dev allow-list (applies to every transport). Bypass sending for any recipient + // not in the list. Skipped when graphDevRestrictTo is empty (i.e. prod). if (config.graphDevRestrictTo) { const allowed = config.graphDevRestrictTo .split(',') @@ -139,15 +223,29 @@ async function sendEmail({ to, subject, text, html, fromName, rawSubject }) { return { sent: false, reason: 'dev_restricted' }; } } + const finalSubject = rawSubject ? subject : `[ScreenTinker] ${subject}`; + const finalHtml = html || `
${escapeHtml(text || '')}
`; try { - const token = await getAccessToken(); - await postSendMail(token, buildSendMailPayload(to, subject, text, html, fromName, rawSubject)); + if (TRANSPORT === 'smtp') { + await smtpSend(to, finalSubject, text, finalHtml, fromName); + } else { + const token = await getAccessToken(); + await postSendMail(token, buildGraphPayload(to, finalSubject, finalHtml, fromName)); + } console.log(`[EMAIL] sent to ${to}: ${subject}`); return { sent: true }; } catch (e) { - console.error(`[EMAIL] Graph send failed for ${to}: ${e.message}`); - return { sent: false, reason: 'graph_error', error: e.message }; + console.error(`[EMAIL] ${TRANSPORT} send failed for ${to}: ${e.message}`); + return { sent: false, reason: `${TRANSPORT}_error`, error: e.message }; } } -module.exports = { sendEmail, isConfigured }; +module.exports = { + sendEmail, + isConfigured, + emailConfigStatus, + // exported for tests + buildSmtpMessage, + buildGraphPayload, + smtpFromAddress, +}; diff --git a/server/test/email-transport.test.js b/server/test/email-transport.test.js new file mode 100644 index 0000000..2c27e3c --- /dev/null +++ b/server/test/email-transport.test.js @@ -0,0 +1,175 @@ +// #173: pluggable email transport (Microsoft Graph default, SMTP alternative). +// These tests drive services/email.js by loading it fresh under different env, +// and mock nodemailer via require.cache so the SMTP path is exercised with no +// network. config.js reads process.env directly (no dotenv), so busting its +// cache re-reads whatever we set here. + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const CONFIG = require.resolve('../config.js'); +const EMAIL = require.resolve('../services/email.js'); +const NODEMAILER = require.resolve('nodemailer'); + +const EMAIL_ENV_KEYS = [ + 'EMAIL_TRANSPORT', + 'SMTP_HOST', 'SMTP_PORT', 'SMTP_SECURE', 'SMTP_USER', 'SMTP_PASSWORD', 'SMTP_FROM', + 'GRAPH_TENANT_ID', 'GRAPH_CLIENT_ID', 'GRAPH_CLIENT_SECRET', 'GRAPH_SENDER_EMAIL', + 'GRAPH_SENDER_NAME', 'GRAPH_DEV_RESTRICT_TO', +]; + +// Load email.js fresh under a specific env. mockSmtp: 'ok' captures sendMail +// calls; 'throw' makes sendMail reject (transport error path). +function loadEmail(env, { mockSmtp } = {}) { + for (const k of EMAIL_ENV_KEYS) delete process.env[k]; + Object.assign(process.env, env); + delete require.cache[CONFIG]; + delete require.cache[EMAIL]; + const captured = { sendMail: [] }; + if (mockSmtp) { + const sendMail = mockSmtp === 'throw' + ? async () => { throw new Error('SMTP connect ECONNREFUSED'); } + : async (msg) => { captured.sendMail.push(msg); return { messageId: 'test' }; }; + require.cache[NODEMAILER] = { + id: NODEMAILER, filename: NODEMAILER, loaded: true, + exports: { createTransport: () => ({ sendMail }) }, + }; + } else { + delete require.cache[NODEMAILER]; + } + return { mod: require(EMAIL), captured }; +} + +const SMTP_OK = { EMAIL_TRANSPORT: 'smtp', SMTP_HOST: 'mail.example.com', SMTP_PORT: '587', SMTP_FROM: 'ScreenTinker ' }; + +// ─────────────── transport selection & config validation ─────────────── + +test('defaults to graph transport when EMAIL_TRANSPORT is unset', () => { + const { mod } = loadEmail({}); + const es = mod.emailConfigStatus(); + assert.equal(es.transport, 'graph'); + assert.equal(es.configured, false); // no GRAPH_* set + assert.equal(mod.isConfigured(), false); +}); + +test('graph reports configured when all four core vars are set', () => { + const { mod } = loadEmail({ + GRAPH_TENANT_ID: 't', GRAPH_CLIENT_ID: 'c', GRAPH_CLIENT_SECRET: 's', GRAPH_SENDER_EMAIL: 'a@b.com', + }); + assert.equal(mod.isConfigured(), true); + assert.equal(mod.emailConfigStatus().configured, true); + assert.deepEqual(mod.emailConfigStatus().missing, []); +}); + +test('smtp with missing fields is not configured and lists what is missing', () => { + const { mod } = loadEmail({ EMAIL_TRANSPORT: 'smtp', SMTP_HOST: 'mail.example.com' }); + const es = mod.emailConfigStatus(); + assert.equal(es.transport, 'smtp'); + assert.equal(es.configured, false); + assert.equal(mod.isConfigured(), false); + assert.equal(es.partiallyConfigured, true); // some set, some missing + assert.ok(es.missing.includes('SMTP_PORT')); + assert.ok(es.missing.some(m => m.startsWith('SMTP_FROM'))); +}); + +test('smtp fully configured (host+port+from) is configured', () => { + const { mod } = loadEmail(SMTP_OK); + assert.equal(mod.isConfigured(), true); + assert.deepEqual(mod.emailConfigStatus().missing, []); +}); + +test('smtp with a user but no password is flagged as misconfigured', () => { + const { mod } = loadEmail({ ...SMTP_OK, SMTP_USER: 'u@example.com' }); // no SMTP_PASSWORD + const es = mod.emailConfigStatus(); + assert.equal(es.configured, false); + assert.ok(es.missing.includes('SMTP_PASSWORD')); +}); + +test('an unset transport is "not configured" but NOT flagged as partial misconfig', () => { + const { mod } = loadEmail({ EMAIL_TRANSPORT: 'smtp' }); // nothing else + const es = mod.emailConfigStatus(); + assert.equal(es.configured, false); + assert.equal(es.partiallyConfigured, false); // nothing set at all → silent fallback, not an error +}); + +test('invalid EMAIL_TRANSPORT falls back to graph and is flagged', () => { + const { mod } = loadEmail({ + EMAIL_TRANSPORT: 'sendgrid', + GRAPH_TENANT_ID: 't', GRAPH_CLIENT_ID: 'c', GRAPH_CLIENT_SECRET: 's', GRAPH_SENDER_EMAIL: 'a@b.com', + }); + const es = mod.emailConfigStatus(); + assert.equal(es.transport, 'graph'); + assert.equal(es.invalidTransport, true); + assert.equal(es.rawTransport, 'sendgrid'); + assert.equal(mod.isConfigured(), true); // graph is fully set, so still usable +}); + +// ─────────────── SMTP message building ─────────────── + +test('buildSmtpMessage uses SMTP_FROM verbatim and keeps a text alternative', () => { + const { mod } = loadEmail(SMTP_OK); + const msg = mod.buildSmtpMessage('to@x.com', '[ScreenTinker] Hi', 'plain body', '

plain body

'); + assert.equal(msg.from, 'ScreenTinker '); + assert.equal(msg.to, 'to@x.com'); + assert.equal(msg.subject, '[ScreenTinker] Hi'); + assert.equal(msg.html, '

plain body

'); + assert.equal(msg.text, 'plain body'); +}); + +test('buildSmtpMessage fromName override keeps the configured address, drops empty text', () => { + const { mod } = loadEmail(SMTP_OK); + const msg = mod.buildSmtpMessage('to@x.com', 'S', null, '

x

', 'Alerts'); + assert.deepEqual(msg.from, { name: 'Alerts', address: 'noreply@example.com' }); + assert.equal('text' in msg, false); +}); + +test('smtpFromAddress parses "Name ", bare addr, and falls back to SMTP_USER', () => { + assert.equal(loadEmail({ ...SMTP_OK, SMTP_FROM: 'A B ' }).mod.smtpFromAddress(), 'a@b.com'); + assert.equal(loadEmail({ ...SMTP_OK, SMTP_FROM: 'c@d.com' }).mod.smtpFromAddress(), 'c@d.com'); + assert.equal(loadEmail({ EMAIL_TRANSPORT: 'smtp', SMTP_HOST: 'h', SMTP_PORT: '587', SMTP_USER: 'u@e.com', SMTP_PASSWORD: 'p' }).mod.smtpFromAddress(), 'u@e.com'); +}); + +// ─────────────── sendEmail routing (SMTP path, mocked) ─────────────── + +test('sendEmail via smtp routes to nodemailer with the [ScreenTinker] prefix', async () => { + const { mod, captured } = loadEmail(SMTP_OK, { mockSmtp: 'ok' }); + const r = await mod.sendEmail({ to: 'user@x.com', subject: 'Hello', text: 'hi there' }); + assert.deepEqual(r, { sent: true }); + assert.equal(captured.sendMail.length, 1); + assert.equal(captured.sendMail[0].subject, '[ScreenTinker] Hello'); + assert.equal(captured.sendMail[0].to, 'user@x.com'); + assert.equal(captured.sendMail[0].from, 'ScreenTinker '); + assert.match(captured.sendMail[0].html, /hi there/); +}); + +test('sendEmail rawSubject sends the subject without the prefix', async () => { + const { mod, captured } = loadEmail(SMTP_OK, { mockSmtp: 'ok' }); + await mod.sendEmail({ to: 'user@x.com', subject: 'Welcome to ScreenTinker', html: '

hi

', rawSubject: true }); + assert.equal(captured.sendMail[0].subject, 'Welcome to ScreenTinker'); +}); + +test('sendEmail via unconfigured smtp is a no-op (not_configured), never sends', async () => { + const { mod, captured } = loadEmail({ EMAIL_TRANSPORT: 'smtp', SMTP_HOST: 'mail.example.com' }, { mockSmtp: 'ok' }); + const r = await mod.sendEmail({ to: 'u@x.com', subject: 'X', text: 'y' }); + assert.equal(r.sent, false); + assert.equal(r.reason, 'not_configured'); + assert.equal(captured.sendMail.length, 0); +}); + +test('dev restrict allow-list applies to the smtp transport too', async () => { + const { mod, captured } = loadEmail({ ...SMTP_OK, GRAPH_DEV_RESTRICT_TO: 'ok@x.com' }, { mockSmtp: 'ok' }); + const blocked = await mod.sendEmail({ to: 'stranger@x.com', subject: 'S', text: 't' }); + assert.equal(blocked.reason, 'dev_restricted'); + assert.equal(captured.sendMail.length, 0); + const allowed = await mod.sendEmail({ to: 'ok@x.com', subject: 'S', text: 't' }); + assert.equal(allowed.sent, true); + assert.equal(captured.sendMail.length, 1); +}); + +test('sendEmail returns smtp_error and never throws when the transport fails', async () => { + const { mod } = loadEmail(SMTP_OK, { mockSmtp: 'throw' }); + const r = await mod.sendEmail({ to: 'user@x.com', subject: 'Hello', text: 'hi' }); + assert.equal(r.sent, false); + assert.equal(r.reason, 'smtp_error'); + assert.match(r.error, /ECONNREFUSED/); +});