feat(signup): welcome email + admin signup notification (slice 1)

Every new user now gets a personal welcome email from
"Dan at ScreenTinker" <support@screentinker.com>, and Dan gets an
admin notification, immediately after signup. Fired from all three
signup paths (local /register, Google, Microsoft) via a shared
helper (services/signupEmails.js) at the new-user branch only, so
OAuth logins of existing users don't re-trigger.

- Reuses the single Microsoft Graph transport (services/email.js).
  Adds two optional, backward-compatible params: fromName (custom
  From display name; address stays support@ so replies route there)
  and rawSubject (skip the "[ScreenTinker] " prefix for clean
  subjects "Welcome to ScreenTinker" / "New signup: <email>").
- Idempotency: users.welcome_email_sent_at, stamped after the send
  block; non-null short-circuits so a user is only emailed once.
  Paired backfill stamps all pre-existing users with sentinel 1 so
  a future "IS NULL" sweep can't mistake the legacy base for
  un-welcomed and blast them.
- Production-only: gated on !config.selfHosted so self-host
  operators never emit mail from our domain or CC Dan.
- No retry logic by design (no re-trigger path on existing users);
  per-email {sent, reason} is logged so a Graph hiccup is visible.

Admin notification includes workspace org name, email, UTC + Central
timestamp, client IP (CF-aware), CF-IPCountry, and user agent.
This commit is contained in:
ScreenTinker 2026-05-30 14:50:27 -05:00
parent d7e3ae6076
commit b67fbaa1b6
4 changed files with 209 additions and 5 deletions

View file

@ -142,6 +142,17 @@ const migrations = [
// playlist_items conversion (migrateAssignmentsToPlaylists) dropped this
// column. Column ADD is idempotent via the surrounding try/catch loop.
"ALTER TABLE playlist_items ADD COLUMN zone_id TEXT REFERENCES layout_zones(id) ON DELETE SET NULL",
// Slice 1: idempotency guard for the one-time signup welcome/admin emails.
// Non-null = this user has already been handled, so we never double-send.
// New signups are stamped with the real unix-seconds time the send block ran
// (see services/signupEmails.js). The paired backfill below stamps every
// pre-existing user with the sentinel value 1, so that a future "IS NULL"
// sweep/nudge can't mistake the legacy user base for un-welcomed accounts and
// blast all of them. Sentinel 1 (vs a real timestamp) also lets a later
// deliberate campaign tell "backfilled, never emailed" apart from "genuinely
// sent at <time>". The backfill is idempotent: re-runs match nothing.
"ALTER TABLE users ADD COLUMN welcome_email_sent_at INTEGER",
"UPDATE users SET welcome_email_sent_at = 1 WHERE welcome_email_sent_at IS NULL",
];
for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ }

View file

@ -8,6 +8,7 @@ const { db } = require('../db/database');
const { generateToken, requireAuth, requireAdmin, requireSuperAdmin, PLATFORM_ROLES } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const { logActivity, getClientIp } = require('../services/activity');
const { sendSignupEmails } = require('../services/signupEmails');
const config = require('../config');
// Phase 2.1: find or create the user's default org+workspace. Returns the
@ -112,6 +113,9 @@ router.post('/register', (req, res) => {
const token = generateToken(user, workspaceId);
res.status(201).json({ token, user, current_workspace_id: workspaceId });
// Welcome + admin-notify emails (hosted instance only, idempotent, async).
sendSignupEmails(user, req);
});
// Login
@ -152,6 +156,7 @@ router.post('/google', async (req, res) => {
// Find or create user
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
const isNewUser = !user;
if (!user) {
if (!canRegister()) {
@ -186,6 +191,9 @@ router.post('/google', async (req, res) => {
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });
// Welcome + admin-notify only when this Google login created a new account.
if (isNewUser) sendSignupEmails(user, req);
} catch (err) {
console.error('Google auth error:', err);
res.status(401).json({ error: 'Google authentication failed' });
@ -231,6 +239,7 @@ router.post('/microsoft', async (req, res) => {
// Find or create user
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
const isNewUser = !user;
if (!user) {
if (!canRegister()) {
@ -263,6 +272,9 @@ router.post('/microsoft', async (req, res) => {
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });
// Welcome + admin-notify only when this Microsoft login created a new account.
if (isNewUser) sendSignupEmails(user, req);
} catch (err) {
console.error('Microsoft auth error:', err);
res.status(401).json({ error: 'Microsoft authentication failed' });

View file

@ -87,10 +87,14 @@ function postSendMail(token, payload) {
});
}
function buildSendMailPayload(to, subject, text, html) {
// 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) {
return {
message: {
subject: `[ScreenTinker] ${subject}`,
subject: rawSubject ? subject : `[ScreenTinker] ${subject}`,
body: {
contentType: 'HTML',
content: html || `<pre style="font-family:sans-serif">${escapeHtml(text || '')}</pre>`,
@ -99,7 +103,7 @@ function buildSendMailPayload(to, subject, text, html) {
from: {
emailAddress: {
address: config.graphSenderEmail,
name: config.graphSenderName || 'ScreenTinker',
name: fromName || config.graphSenderName || 'ScreenTinker',
},
},
},
@ -117,7 +121,7 @@ function escapeHtml(s) {
// 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 }) {
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]}`);
@ -137,7 +141,7 @@ async function sendEmail({ to, subject, text, html }) {
}
try {
const token = await getAccessToken();
await postSendMail(token, buildSendMailPayload(to, subject, text, html));
await postSendMail(token, buildSendMailPayload(to, subject, text, html, fromName, rawSubject));
console.log(`[EMAIL] sent to ${to}: ${subject}`);
return { sent: true };
} catch (e) {

View file

@ -0,0 +1,177 @@
// One-time signup emails (Slice 1):
// (a) a personal welcome email to the new user, and
// (b) an admin notification to Dan so no signup goes unnoticed.
//
// Fired fire-and-forget from all three signup paths (local /register, /google,
// /microsoft) at the point a NEW user is created. Reuses the single Microsoft
// Graph transport in ./email (no second mail path).
//
// Gating & safety:
// - Hosted-instance only: skipped when SELF_HOSTED=true so self-host operators
// never emit mail from our domain (and never CC Dan on their signups).
// - Idempotent: users.welcome_email_sent_at is stamped after the send block;
// a non-null value short-circuits, so a user is only ever emailed once.
// - sendEmail() never throws, so a Graph hiccup is logged (per-email
// {sent, reason}) but never blocks or fails the signup request.
//
// No retry logic by design: there is no path that re-enters the new-user branch
// for an existing user, so a failed Graph send is surfaced in the logs and left
// alone rather than retried (that code would be dead).
const { db } = require('../db/database');
const { sendEmail } = require('./email');
const { getClientIp } = require('./activity');
const config = require('../config');
const ADMIN_NOTIFY_TO = 'dw5304@gmail.com';
const LINKS = {
player: 'https://screentinker.com/player/',
pi: 'https://screentinker.com/guides/raspberry-pi-digital-signage.html',
androidTv: 'https://screentinker.com/guides/digital-signage-android-tv.html',
selfHosted: 'https://screentinker.com/guides/self-hosted-digital-signage.html',
discord: 'https://discord.gg/utTdsrqq4Z',
};
function htmlEscape(s) {
return String(s).replace(/[&<>"']/g, c =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
// Plain-text body. Pure ASCII on purpose: "->" not the arrow glyph, "-" not the
// bullet glyph, straight apostrophes, no em-dashes. Unicode in text/plain gets
// mangled by some clients and hurts deliverability on a new sending pattern.
function welcomeText(name) {
return `Hi ${name},
Thanks for signing up for ScreenTinker. Glad you're here.
One thing worth knowing up front. ScreenTinker is run by one person, me.
There's no support queue or ticket robot. If you hit reply to this email,
it comes straight to me and I'll answer.
The fastest way to see it work is to put something on a screen. You can turn
any browser into a display in about a minute with the web player:
-> ${LINKS.player}
Open that on whatever you want to use as a screen, pair it from your
dashboard, and you're live.
Using real signage hardware? These walk you through it:
- Raspberry Pi: ${LINKS.pi}
- Android TV: ${LINKS.androidTv}
- Self-hosted: ${LINKS.selfHosted}
Want to ask a human or see what others are building? Discord's here:
${LINKS.discord}
Just hit reply if anything's unclear or not working. I read every email.
- Dan
ScreenTinker`;
}
function welcomeHtml(name) {
return `<div style="font-family:-apple-system,'Segoe UI',Roboto,sans-serif;font-size:15px;line-height:1.6;color:#222;max-width:560px">
<p>Hi ${htmlEscape(name)},</p>
<p>Thanks for signing up for ScreenTinker. Glad you're here.</p>
<p>One thing worth knowing up front. ScreenTinker is run by one person, me. There's no support queue or ticket robot. If you hit reply to this email, it comes straight to me and I'll answer.</p>
<p>The fastest way to see it work is to put something on a screen. You can turn any browser into a display in about a minute with the web player:</p>
<p><a href="${LINKS.player}" style="font-weight:600">Open the web player</a></p>
<p>Open that on whatever you want to use as a screen, pair it from your dashboard, and you're live.</p>
<p>Using real signage hardware? These walk you through it:</p>
<ul>
<li><a href="${LINKS.pi}">Raspberry Pi setup</a></li>
<li><a href="${LINKS.androidTv}">Android TV setup</a></li>
<li><a href="${LINKS.selfHosted}">Self-hosted setup</a></li>
</ul>
<p>Want to ask a human or see what others are building? <a href="${LINKS.discord}">Discord's here</a>.</p>
<p>Just hit reply if anything's unclear or not working. I read every email.</p>
<p>- Dan<br>ScreenTinker</p>
</div>`;
}
function fmtUtc(unixSec) {
return new Date(unixSec * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
}
function fmtCentral(unixSec) {
return new Date(unixSec * 1000).toLocaleString('en-US', {
timeZone: 'America/Chicago',
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: true,
});
}
function adminText({ name, email, orgName, signupUnix, ip, country, userAgent }) {
return `New ScreenTinker signup.
Name: ${name}
Email: ${email}
Org: ${orgName}
Plan: pro (14-day trial)
Signed up: ${fmtUtc(signupUnix)} (${fmtCentral(signupUnix)} America/Chicago)
IP: ${ip || 'unknown'}
Country: ${country || 'unknown'}
User agent: ${userAgent || 'unknown'}`;
}
// Public entry point. `user` only needs `.id`; everything else is re-read from
// the row so the caller's column selection doesn't matter. `req` supplies the
// client IP (CF-aware), Cloudflare's free CF-IPCountry header, and user agent.
function sendSignupEmails(user, req) {
try {
// Hosted instance only.
if (config.selfHosted) return;
const row = db.prepare(
'SELECT email, name, created_at, welcome_email_sent_at FROM users WHERE id = ?'
).get(user.id);
if (!row || row.welcome_email_sent_at) return; // unknown or already handled
const email = row.email;
const name = (row.name && row.name.trim()) ? row.name.trim() : email.split('@')[0];
const signupUnix = row.created_at || Math.floor(Date.now() / 1000);
// Workspace name is always "Default" at signup, so use the org name instead.
const orgRow = db.prepare(
'SELECT name FROM organizations WHERE owner_user_id = ? ORDER BY created_at ASC LIMIT 1'
).get(user.id);
const orgName = orgRow ? orgRow.name : `${name}'s organization`;
const ip = getClientIp(req);
const country = (req && req.headers && req.headers['cf-ipcountry']) || 'unknown';
const userAgent = (req && req.headers && req.headers['user-agent']) || 'unknown';
(async () => {
const w = await sendEmail({
to: email,
fromName: 'Dan at ScreenTinker',
rawSubject: true,
subject: 'Welcome to ScreenTinker',
text: welcomeText(name),
html: welcomeHtml(name),
});
console.log(`[SIGNUP-EMAIL] welcome -> ${email}: ${JSON.stringify(w)}`);
const a = await sendEmail({
to: ADMIN_NOTIFY_TO,
rawSubject: true,
subject: `New signup: ${email}`,
text: adminText({ name, email, orgName, signupUnix, ip, country, userAgent }),
});
console.log(`[SIGNUP-EMAIL] admin-notify (${email}) -> ${ADMIN_NOTIFY_TO}: ${JSON.stringify(a)}`);
// Stamp after the send block regardless of per-email outcome (no retry):
// marks this user handled so we never double-send.
db.prepare("UPDATE users SET welcome_email_sent_at = strftime('%s','now') WHERE id = ?")
.run(user.id);
})().catch(e => console.error(`[SIGNUP-EMAIL] unexpected failure for ${email}: ${e.message}`));
} catch (e) {
// Never let signup-email bookkeeping affect the signup request itself.
console.error(`[SIGNUP-EMAIL] setup failed: ${e.message}`);
}
}
module.exports = { sendSignupEmails };