mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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 <addr>". 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 <noreply@anthropic.com>
This commit is contained in:
parent
406872c439
commit
cf4c71d7d0
32
.env.example
32
.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 <addr>".
|
||||
# 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 <noreply@example.com>
|
||||
|
||||
# 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
|
||||
|
|
|
|||
47
README.md
47
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 <addr@example.com>` 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 <you@gmail.com>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <addr>" 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
|
||||
|
|
|
|||
10
server/package-lock.json
generated
10
server/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 || `<pre style="font-family:sans-serif">${escapeHtml(text || '')}</pre>`,
|
||||
},
|
||||
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 <a@b.com>" 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 || `<pre style="font-family:sans-serif">${escapeHtml(text || '')}</pre>`;
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
175
server/test/email-transport.test.js
Normal file
175
server/test/email-transport.test.js
Normal file
|
|
@ -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 <noreply@example.com>' };
|
||||
|
||||
// ─────────────── 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', '<p>plain body</p>');
|
||||
assert.equal(msg.from, 'ScreenTinker <noreply@example.com>');
|
||||
assert.equal(msg.to, 'to@x.com');
|
||||
assert.equal(msg.subject, '[ScreenTinker] Hi');
|
||||
assert.equal(msg.html, '<p>plain body</p>');
|
||||
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, '<p>x</p>', 'Alerts');
|
||||
assert.deepEqual(msg.from, { name: 'Alerts', address: 'noreply@example.com' });
|
||||
assert.equal('text' in msg, false);
|
||||
});
|
||||
|
||||
test('smtpFromAddress parses "Name <addr>", bare addr, and falls back to SMTP_USER', () => {
|
||||
assert.equal(loadEmail({ ...SMTP_OK, SMTP_FROM: 'A B <a@b.com>' }).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 <noreply@example.com>');
|
||||
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: '<p>hi</p>', 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/);
|
||||
});
|
||||
Loading…
Reference in a new issue