← back to Wallco Ai
trade-login: wire transactional email send via sendTransactionalEmail() — auto-detects Resend (RESEND_API_KEY) or SMTP (SMTP_HOST+USER+PASS via lazy nodemailer). Falls back to existing copy-link UI when no provider configured. /api/trade/login now returns {sent, provider} flags; frontend shows 'Check your inbox' on send success and the copy-link UI otherwise
78c466d347f7043a5441bb372ac3ad7753f7624e · 2026-05-28 08:23:47 -0700 · Steve Abrams
Files touched
Diff
commit 78c466d347f7043a5441bb372ac3ad7753f7624e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 08:23:47 2026 -0700
trade-login: wire transactional email send via sendTransactionalEmail() — auto-detects Resend (RESEND_API_KEY) or SMTP (SMTP_HOST+USER+PASS via lazy nodemailer). Falls back to existing copy-link UI when no provider configured. /api/trade/login now returns {sent, provider} flags; frontend shows 'Check your inbox' on send success and the copy-link UI otherwise
---
server.js | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 113 insertions(+), 17 deletions(-)
diff --git a/server.js b/server.js
index e37b3ad..d193672 100644
--- a/server.js
+++ b/server.js
@@ -3184,8 +3184,12 @@ app.post('/api/designs/bulk-action', express.json({ limit: '64kb' }), (req, res)
// ── TRADE LOGIN (separate from admin layer) — magic-link, no passwords.
// Sister cookie to dw_auth; different name (`wallco_trade_session`), different
// table (wallco_trade_users), so admin and trade tiers never collide.
-// Per Steve's standing rule: NO autonomous email sending. The magic link is
-// logged to console + echoed in the API response. Wire SES/sendgrid later.
+//
+// Magic-link email: wired via sendTransactionalEmail() below. Auto-detects
+// provider from env (RESEND_API_KEY → Resend; SMTP_HOST+SMTP_USER+SMTP_PASS
+// → SMTP via nodemailer). When no provider creds present, falls back to the
+// current "copy this link" UI (dev mode). Transactional only — CAN-SPAM/§17529.5
+// exemption applies (no unsubscribe required) but use a real From identity.
const jwt = require('jsonwebtoken');
const WALLCO_JWT_SECRET = resolveSecret(
'WALLCO_JWT_SECRET / JWT_SECRET',
@@ -3403,15 +3407,25 @@ ${FOOTER}
return;
}
msg.style.color = '';
- msg.innerHTML = '<div style="padding:14px;background:#faf8f3;border:1px solid #e6e2d8;border-radius:6px">'
- + '<div style="font-weight:500;margin-bottom:6px">Sign-in link ready</div>'
- + '<div style="font:12px/1.4 var(--sans);color:var(--ink-soft);margin-bottom:8px">'
- + 'Email sending isn’t wired yet — copy this link to sign in:'
- + '</div>'
- + '<a href="' + out.j.magic_link + '" style="display:block;font:12px/1.5 ui-monospace,monospace;word-break:break-all;color:var(--accent,#d2b15c);text-decoration:underline;margin-bottom:8px">'
- + out.j.magic_link + '</a>'
- + '<a href="' + out.j.magic_link + '" class="btn-primary" style="display:inline-block;padding:8px 14px;font:13px var(--sans);text-decoration:none">Sign in now →</a>'
- + '</div>';
+ if (out.j.sent === true) {
+ // Email actually went out — show "check your inbox" and DON'T leak the link.
+ msg.innerHTML = '<div style="padding:14px;background:#f1f7ef;border:1px solid #cfe3c9;border-radius:6px">'
+ + '<div style="font-weight:500;margin-bottom:6px;color:#2e6a25">✓ Sign-in link sent</div>'
+ + '<div style="font:13px/1.5 var(--sans);color:var(--ink-soft)">'
+ + 'Check your inbox at <b>' + email + '</b>. The link is valid for 30 minutes.'
+ + '</div></div>';
+ } else {
+ // Email provider not configured (or send failed) — fall back to dev copy-link UI.
+ msg.innerHTML = '<div style="padding:14px;background:#faf8f3;border:1px solid #e6e2d8;border-radius:6px">'
+ + '<div style="font-weight:500;margin-bottom:6px">Sign-in link ready</div>'
+ + '<div style="font:12px/1.4 var(--sans);color:var(--ink-soft);margin-bottom:8px">'
+ + 'Email sending isn’t wired yet — copy this link to sign in:'
+ + '</div>'
+ + '<a href="' + out.j.magic_link + '" style="display:block;font:12px/1.5 ui-monospace,monospace;word-break:break-all;color:var(--accent,#d2b15c);text-decoration:underline;margin-bottom:8px">'
+ + out.j.magic_link + '</a>'
+ + '<a href="' + out.j.magic_link + '" class="btn-primary" style="display:inline-block;padding:8px 14px;font:13px var(--sans);text-decoration:none">Sign in now →</a>'
+ + '</div>';
+ }
})
.catch(function(err){ msg.style.color='#b03a2e'; msg.textContent = 'Network error: ' + err.message; });
});
@@ -3422,10 +3436,74 @@ ${HAMBURGER_JS}
</html>`);
});
-// POST /api/trade/login — generate token, store, return magic link.
-// NOTE: Steve standing rule — NO autonomous email sending. The link is
-// console-logged + returned in JSON. Steve will wire SES/sendgrid later.
-app.post('/api/trade/login', (req, res) => {
+// ── Transactional email sender (magic-link delivery) ──────────────────────
+// Auto-detects provider from env:
+// 1) RESEND_API_KEY → Resend HTTPS POST /emails (simplest)
+// 2) SMTP_HOST + SMTP_USER + SMTP_PASS → nodemailer (lazy-require; falls
+// through if module not installed)
+// 3) none → returns { sent:false }, frontend
+// shows the existing copy-link UI
+// MAIL_FROM controls the From identity; defaults to a safe wallco.ai sender.
+async function sendTransactionalEmail(to, subject, html, text) {
+ const FROM = process.env.MAIL_FROM || 'Fentucci Digital Packs <noreply@wallco.ai>';
+
+ // Provider 1: Resend (Bearer-token REST, no extra deps)
+ if (process.env.RESEND_API_KEY) {
+ try {
+ const httpsLocal = require('https');
+ const body = JSON.stringify({ from: FROM, to: [to], subject, html, text });
+ const out = await new Promise((resolve, reject) => {
+ const req = httpsLocal.request({
+ method: 'POST', hostname: 'api.resend.com', path: '/emails',
+ headers: {
+ 'Authorization': `Bearer ${process.env.RESEND_API_KEY}`,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(body),
+ },
+ }, res => {
+ let data = ''; res.on('data', c => data += c);
+ res.on('end', () => {
+ let j; try { j = data ? JSON.parse(data) : {}; } catch { j = { raw: data }; }
+ if (res.statusCode >= 400) return reject(new Error(`Resend ${res.statusCode}: ${j.message || j.error || data.slice(0,200)}`));
+ resolve(j);
+ });
+ });
+ req.on('error', reject); req.write(body); req.end();
+ });
+ console.log(`[mail] sent via Resend → ${to} (id=${out.id})`);
+ return { sent: true, provider: 'resend', id: out.id };
+ } catch (e) {
+ console.warn('[mail] Resend send failed (will try fallback):', e.message);
+ }
+ }
+
+ // Provider 2: SMTP via nodemailer (lazy-require so it isn't a hard dep)
+ if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
+ try {
+ const nodemailer = require('nodemailer');
+ const port = parseInt(process.env.SMTP_PORT || '465', 10);
+ const transport = nodemailer.createTransport({
+ host: process.env.SMTP_HOST, port,
+ secure: port === 465,
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
+ });
+ const info = await transport.sendMail({ from: FROM, to, subject, text, html });
+ console.log(`[mail] sent via SMTP → ${to} (id=${info.messageId})`);
+ return { sent: true, provider: 'smtp', id: info.messageId };
+ } catch (e) {
+ if (e && e.code === 'MODULE_NOT_FOUND')
+ console.warn('[mail] SMTP creds set but nodemailer not installed — run: npm i nodemailer');
+ else console.warn('[mail] SMTP send failed:', e.message);
+ }
+ }
+
+ return { sent: false, reason: 'no email provider configured (set RESEND_API_KEY, or SMTP_HOST+SMTP_USER+SMTP_PASS via secrets-manager)' };
+}
+
+// POST /api/trade/login — generate token, store, send magic link (if email
+// provider wired) AND return it in the JSON so the dev "copy link" UI keeps
+// working when email isn't configured yet.
+app.post('/api/trade/login', async (req, res) => {
const email = String((req.body && req.body.email) || '').trim().toLowerCase();
const returnTo = String((req.body && req.body.return) || '/').slice(0, 500);
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
@@ -3435,7 +3513,6 @@ app.post('/api/trade/login', (req, res) => {
const token = crypto.randomBytes(32).toString('hex');
const expSql = `NOW() + INTERVAL '30 minutes'`;
try {
- // Upsert user; bind a fresh token.
psqlQuery(`INSERT INTO wallco_trade_users (email, magic_token, token_exp)
VALUES (${pgEsc(email)}, ${pgEsc(token)}, ${expSql})
ON CONFLICT (email) DO UPDATE
@@ -3451,11 +3528,30 @@ app.post('/api/trade/login', (req, res) => {
const magicLink = `${proto}://${host}/trade/auth?token=${token}&return=${encodeURIComponent(returnTo)}`;
console.log(`[trade-login] magic link: ${magicLink} (for ${email})`);
logEvent(req, 'trade_login_request', { email });
+
+ // Best-effort transactional send. If a provider is configured + succeeds,
+ // the frontend shows "Check your inbox"; otherwise it keeps the copy-link UI.
+ const subject = 'Sign in to wallco.ai';
+ const html =
+ `<div style="font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1a1a1a;max-width:520px;margin:0 auto;padding:24px">
+ <h1 style="font:600 22px/1.2 inherit;margin:0 0 16px;color:#0a0a0a">Your wallco.ai sign-in link</h1>
+ <p style="margin:0 0 16px">Click the button below to sign in. The link is valid for 30 minutes and can be used once.</p>
+ <p style="margin:0 0 24px"><a href="${magicLink}" style="display:inline-block;padding:12px 22px;background:#d2b15c;color:#0a0a0a;text-decoration:none;border-radius:6px;font-weight:600">Sign in →</a></p>
+ <p style="margin:0 0 8px;color:#888;font-size:12px">Or copy this URL into your browser:</p>
+ <p style="margin:0 0 24px;font:12px/1.4 ui-monospace,monospace;word-break:break-all;color:#666">${magicLink}</p>
+ <hr style="border:none;border-top:1px solid #eee;margin:24px 0">
+ <p style="margin:0;font:11.5px/1.4 inherit;color:#888">You're getting this because someone (likely you) requested a sign-in link at wallco.ai. If it wasn't you, ignore — the link won't sign anyone in unless they click it.</p>
+ </div>`;
+ const text = `Sign in to wallco.ai\n\n${magicLink}\n\n(valid for 30 minutes, single use)\n\nIf you didn't request this, you can ignore it.`;
+ const mail = await sendTransactionalEmail(email, subject, html, text);
+
res.json({
ok: true,
magic_link: magicLink,
expires_in: '30 minutes',
- note: 'Email sending not yet configured — copy the link above.'
+ sent: mail.sent === true,
+ provider: mail.provider || null,
+ note: mail.sent ? `Sign-in link emailed via ${mail.provider}.` : (mail.reason || 'Email not configured — copy the link.'),
});
});
← 0f523b9 design page: collapse the always-visible palette-strip on lo
·
back to Wallco Ai
·
night-builder: land UNPUBLISHED — every new comfy roll requi 8581f87 →