[object Object]

← back to NationalPaperHangers

v0.5: NPH email through Purelymail SMTP + tighter /book calendar gate

196af634deab089d61e5ebf8b37ec60b63368280 · 2026-05-06 14:53:42 -0700 · SteveStudio2

lib/email.js — direct nodemailer→smtp.purelymail.com:587 with
INFO_NATIONALPAPERHANGERS_COM_PASSWORD. Was sending through George account=info
which actually maps to a Gmail label on Steve's DW Workspace, so From was
steve@designerwallcoverings.com — wrong domain for SPF/DKIM/DMARC alignment.
Verified live on Kamatera 2026-05-06: messageId
<...@nationalpaperhangers.com> (Purelymail SMTP, not Gmail).

routes/public.js + book.ejs — /book calendar now requires THREE conditions,
not just paid tier:
  1. claim_status in (self, claimed) — real installer-user behind the listing
  2. tier in (pro, signature, enterprise) — paid plan
  3. ≥1 active installer_availability window
Any missing → fall through to contact-direct callout with reason-specific
copy (calendarBlockedReason: unclaimed | free_tier | no_schedule). The
unclaimed branch points the visitor to /installer/:slug#claim with prompt
to claim their listing.

Files touched

Diff

commit 196af634deab089d61e5ebf8b37ec60b63368280
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 14:53:42 2026 -0700

    v0.5: NPH email through Purelymail SMTP + tighter /book calendar gate
    
    lib/email.js — direct nodemailer→smtp.purelymail.com:587 with
    INFO_NATIONALPAPERHANGERS_COM_PASSWORD. Was sending through George account=info
    which actually maps to a Gmail label on Steve's DW Workspace, so From was
    steve@designerwallcoverings.com — wrong domain for SPF/DKIM/DMARC alignment.
    Verified live on Kamatera 2026-05-06: messageId
    <...@nationalpaperhangers.com> (Purelymail SMTP, not Gmail).
    
    routes/public.js + book.ejs — /book calendar now requires THREE conditions,
    not just paid tier:
      1. claim_status in (self, claimed) — real installer-user behind the listing
      2. tier in (pro, signature, enterprise) — paid plan
      3. ≥1 active installer_availability window
    Any missing → fall through to contact-direct callout with reason-specific
    copy (calendarBlockedReason: unclaimed | free_tier | no_schedule). The
    unclaimed branch points the visitor to /installer/:slug#claim with prompt
    to claim their listing.
---
 lib/email.js          | 82 +++++++++++++++++++++++++++------------------------
 package-lock.json     | 10 +++++++
 package.json          |  1 +
 routes/public.js      | 33 +++++++++++++++++++--
 views/public/book.ejs | 22 ++++++++++++--
 5 files changed, 103 insertions(+), 45 deletions(-)

diff --git a/lib/email.js b/lib/email.js
index 3a9f46d..fc64374 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -1,51 +1,55 @@
-// George Gmail agent client.
-// Per Steve's standing rules: every site has its own info@ — this one uses
-// info@nationalpaperhangers.com via the local George agent (account=info).
-// In dev, falls back to console logging if George isn't reachable.
+// NPH email — direct Purelymail SMTP via nodemailer.
+// Mailbox: info@nationalpaperhangers.com (Purelymail). Password lives in /secrets
+// as INFO_NATIONALPAPERHANGERS_COM_PASSWORD; lands in NPH .env on sync.
+//
+// Why not George? George's `info` account is a Gmail label on Steve's DW
+// Workspace and sends FROM steve@designerwallcoverings.com. NPH needs From to
+// match the apex domain for SPF/DKIM/DMARC alignment + brand integrity.
+// In dev, falls back to console logging if SMTP creds are absent.
 
-const GEORGE_URL = process.env.GEORGE_URL || 'http://localhost:9850';
-const GEORGE_ACCOUNT = process.env.GEORGE_ACCOUNT || 'info';
-const GEORGE_USER = process.env.GEORGE_USER || '';
-const GEORGE_PASS = process.env.GEORGE_PASS || '';
+const nodemailer = require('nodemailer');
+
+const SMTP_HOST = process.env.NPH_SMTP_HOST || 'smtp.purelymail.com';
+const SMTP_PORT = parseInt(process.env.NPH_SMTP_PORT || '587', 10);
+const SMTP_USER = process.env.NPH_SMTP_USER || process.env.EMAIL_FROM || 'info@nationalpaperhangers.com';
+const SMTP_PASS = process.env.NPH_SMTP_PASS || process.env.INFO_NATIONALPAPERHANGERS_COM_PASSWORD || '';
 const FROM = process.env.EMAIL_FROM || 'info@nationalpaperhangers.com';
 const FROM_NAME = process.env.EMAIL_FROM_NAME || 'National Paper Hangers';
 
+let _transport = null;
+function getTransport() {
+  if (_transport) return _transport;
+  if (!SMTP_PASS) return null; // mock mode
+  _transport = nodemailer.createTransport({
+    host: SMTP_HOST,
+    port: SMTP_PORT,
+    secure: SMTP_PORT === 465,        // 465 = implicit TLS, 587 = STARTTLS
+    requireTLS: SMTP_PORT === 587,
+    auth: { user: SMTP_USER, pass: SMTP_PASS }
+  });
+  return _transport;
+}
+
 async function sendEmail({ to, subject, html, text, extraHeaders }) {
-  const url = `${GEORGE_URL}/api/send?account=${encodeURIComponent(GEORGE_ACCOUNT)}`;
-  const body = {
-    to,
-    subject,
-    body: html || text,
-    from: FROM,
-    fromName: FROM_NAME
-  };
-  // Pass through RFC 2369 / RFC 8058 List-Unsubscribe + List-Unsubscribe-Post
-  // headers when supplied. George must propagate these to SMTP.
-  if (extraHeaders && typeof extraHeaders === 'object') {
-    body.headers = extraHeaders;
-  }
-  const headers = { 'content-type': 'application/json' };
-  if (GEORGE_USER && GEORGE_PASS) {
-    headers['authorization'] = 'Basic ' + Buffer.from(`${GEORGE_USER}:${GEORGE_PASS}`).toString('base64');
+  const transport = getTransport();
+  if (!transport) {
+    console.warn('[email] no SMTP creds (NPH_SMTP_PASS/INFO_NATIONALPAPERHANGERS_COM_PASSWORD unset); logging instead');
+    console.log('--- EMAIL ---\nto:', to, '\nsubject:', subject, '\nbody:\n', (html || text || '').slice(0, 500), '\n-------------');
+    return { ok: false, mocked: true };
   }
   try {
-    const r = await fetch(url, {
-      method: 'POST',
-      headers,
-      body: JSON.stringify(body),
-      signal: AbortSignal.timeout(8000)
+    const info = await transport.sendMail({
+      from: { name: FROM_NAME, address: FROM },
+      to,
+      subject,
+      html: html || undefined,
+      text: text || undefined,
+      headers: (extraHeaders && typeof extraHeaders === 'object') ? extraHeaders : undefined
     });
-    if (!r.ok) {
-      const txt = await r.text();
-      console.warn('[email] george non-2xx', r.status, txt.slice(0, 200));
-      return { ok: false, status: r.status, error: txt };
-    }
-    const j = await r.json().catch(() => ({}));
-    return { ok: true, ...j };
+    return { ok: true, messageId: info.messageId, accepted: info.accepted, rejected: info.rejected };
   } catch (err) {
-    console.warn('[email] george unreachable, logging instead:', err.message);
-    console.log('--- EMAIL ---\nto:', to, '\nsubject:', subject, '\nbody:\n', (html || text || '').slice(0, 500), '\n-------------');
-    return { ok: false, mocked: true, error: err.message };
+    console.warn('[email] smtp error:', err.code || err.message);
+    return { ok: false, error: err.message, code: err.code };
   }
 }
 
diff --git a/package-lock.json b/package-lock.json
index 445b38d..b65eaaa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
         "luxon": "^3.5.0",
         "morgan": "^1.10.0",
         "multer": "^2.1.1",
+        "nodemailer": "^8.0.7",
         "pg": "^8.13.1",
         "playwright-core": "^1.59.1",
         "slugify": "^1.6.6",
@@ -1692,6 +1693,15 @@
         }
       }
     },
+    "node_modules/nodemailer": {
+      "version": "8.0.7",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
+      "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/nodemon": {
       "version": "3.1.14",
       "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
diff --git a/package.json b/package.json
index 37b2108..1807e01 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
     "luxon": "^3.5.0",
     "morgan": "^1.10.0",
     "multer": "^2.1.1",
+    "nodemailer": "^8.0.7",
     "pg": "^8.13.1",
     "playwright-core": "^1.59.1",
     "slugify": "^1.6.6",
diff --git a/routes/public.js b/routes/public.js
index 8ee34f0..5a553cb 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -229,13 +229,40 @@ router.get('/installer/:slug/book', async (req, res, next) => {
     );
     if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
 
-    // Free-tier installers don't get the calendar; route to a contact-only page
-    const calendarEnabled = ['pro','signature','enterprise'].includes(installer.tier);
+    // Calendar requires THREE things, not just paid tier (Steve 2026-05-06):
+    //   1. Studio is claimed (claim_status in 'self','claimed') — there's a real
+    //      installer-user behind the listing, not a directory shell.
+    //   2. Subscription tier is pro/signature/enterprise — calendar is paid.
+    //   3. At least one active availability window exists — the studio has
+    //      actually configured their working hours. Without this the slot grid
+    //      renders empty and looks broken.
+    // Any missing → fall through to the contact-direct branch (website / IG / phone).
+    const isClaimed = ['self','claimed'].includes(installer.claim_status);
+    const isPaidTier = ['pro','signature','enterprise'].includes(installer.tier);
+    let hasAvailability = false;
+    if (isClaimed && isPaidTier) {
+      const avail = await db.one(
+        'SELECT COUNT(*)::int AS c FROM installer_availability WHERE installer_id = $1 AND active = true',
+        [installer.id]
+      );
+      hasAvailability = avail.c > 0;
+    }
+    const calendarEnabled = isClaimed && isPaidTier && hasAvailability;
+
+    // Surface WHY the calendar is off so the EJS can show the right copy:
+    //   'unclaimed'    → "Studio hasn't claimed their listing yet"
+    //   'free_tier'    → "Studio is on the free tier; book by contacting directly"
+    //   'no_schedule'  → "Studio is set up but hasn't configured availability yet"
+    let calendarBlockedReason = null;
+    if (!isClaimed)             calendarBlockedReason = 'unclaimed';
+    else if (!isPaidTier)       calendarBlockedReason = 'free_tier';
+    else if (!hasAvailability)  calendarBlockedReason = 'no_schedule';
+
     res.render('public/book', {
       title: `Book ${installer.business_name} · National Paper Hangers`,
       metaDescription: `Schedule a consultation with ${installer.business_name}${installer.city ? ' in ' + installer.city + ', ' + installer.state : ''}. Pick from open slots in their live calendar.`,
       canonicalPath: `/installer/${installer.slug}/book`,
-      installer, calendarEnabled,
+      installer, calendarEnabled, calendarBlockedReason,
       // Stripe publishable key for the booking-deposit UI. Null in mock mode
       // — book.ejs renders a "test mode" callout instead of the card element.
       stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || null
diff --git a/views/public/book.ejs b/views/public/book.ejs
index 4fe8634..8419d76 100644
--- a/views/public/book.ejs
+++ b/views/public/book.ejs
@@ -78,16 +78,32 @@
         catch(e){ return null; }
       }
       var _site = _safeWeb(installer.website);
+      var _reason = (typeof calendarBlockedReason !== 'undefined') ? calendarBlockedReason : 'free_tier';
+      var _heading, _explainer;
+      if (_reason === 'unclaimed') {
+        _heading = 'Available times appear after the studio claims their listing';
+        _explainer = '<strong>' + installer.business_name + '</strong> is in the National Paper Hangers directory but the studio team hasn\'t claimed and configured the listing yet. Real bookable slots become available once they sign up and set their working hours. Until then, reach out directly:';
+      } else if (_reason === 'no_schedule') {
+        _heading = 'Studio hasn\'t set their availability yet';
+        _explainer = '<strong>' + installer.business_name + '</strong> is on a paid plan but hasn\'t finished configuring their calendar. Bookable slots appear once they set working hours. In the meantime, reach them directly:';
+      } else {
+        _heading = 'Self-booking isn\'t enabled for this studio yet';
+        _explainer = 'Reach <strong>' + installer.business_name + '</strong> directly — they typically respond within ' + installer.response_time_hours + ' hours:';
+      }
     %>
     <div class="callout">
-      <h2 style="margin-top:0">Self-booking isn't enabled for this studio yet</h2>
-      <p>Reach <strong><%= installer.business_name %></strong> directly — they typically respond within <%= installer.response_time_hours %> hours:</p>
+      <h2 style="margin-top:0"><%= _heading %></h2>
+      <p><%- _explainer %></p>
       <div class="profile-actions" style="margin:16px 0">
         <% if (_site) { %><a href="<%= _site %>" target="_blank" rel="noopener" class="btn btn-primary">Visit studio site ↗</a><% } %>
         <% if (installer.instagram_handle) { %><a href="https://instagram.com/<%= encodeURIComponent(installer.instagram_handle) %>" target="_blank" rel="noopener" class="btn btn-ghost">@<%= installer.instagram_handle %></a><% } %>
         <% if (installer.phone) { %><a href="tel:<%= installer.phone %>" class="btn btn-ghost">Call <%= installer.phone %></a><% } %>
       </div>
-      <p class="muted">Want to book directly into a calendar instead? <a href="/find">Browse studios on Pro &amp; Signature plans →</a></p>
+      <% if (_reason === 'unclaimed') { %>
+        <p class="muted" style="margin-top:16px;font-size:13px">Are you the owner of <%= installer.business_name %>? <a href="/installer/<%= installer.slug %>#claim">Claim this listing →</a> and configure your calendar to start receiving bookings directly through NPH.</p>
+      <% } else { %>
+        <p class="muted">Want to book directly into a calendar instead? <a href="/find">Browse studios with live calendars →</a></p>
+      <% } %>
       <a href="/installer/<%= installer.slug %>" class="btn btn-ghost btn-sm" style="margin-top:8px">← Back to <%= installer.business_name %>'s profile</a>
     </div>
   <% } else { %>

← 8dd327f v0.5: bootstrap Stripe products+prices+webhook for NPH  ·  back to NationalPaperHangers  ·  v0.6: notify-when-live capture form on unclaimed /book f339c34 →