[object Object]

← 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 →