[object Object]

← back to All Designerwallcoverings

Email Vendor everywhere: stock & price inquiry compose on every product card + microsite card — /api/vendor-contact + /api/vendor-inquiry (vendor_contacts upsert + George Gmail DRAFT, never auto-send, mailto fallback)

3fa3f9f8d235cf2fdaf45cd494a2b28ae4bf41c1 · 2026-07-15 13:33:23 -0700 · Steve Abrams

Files touched

Diff

commit 3fa3f9f8d235cf2fdaf45cd494a2b28ae4bf41c1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 15 13:33:23 2026 -0700

    Email Vendor everywhere: stock & price inquiry compose on every product card + microsite card — /api/vendor-contact + /api/vendor-inquiry (vendor_contacts upsert + George Gmail DRAFT, never auto-send, mailto fallback)
---
 public/index.html      | 122 ++++++++++++++++++++++++++++++++++++++++++++++++-
 public/microsites.html |  95 ++++++++++++++++++++++++++++++++++++++
 server.js              | 115 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 331 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index 9eb4af7..0612f0f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -190,6 +190,25 @@
   .livepop .lcost { color:var(--accent); }
   .lspin { display:inline-block; animation:lspin 1s linear infinite; }
   @keyframes lspin { to { transform:rotate(360deg); } }
+  /* Email Vendor — the stock & price inquiry EVERY vendor gets (blue = correspondence).
+     Composes a Gmail DRAFT via George; never auto-sends. */
+  .acts .im.mailbtn, td.acts-td .im.mailbtn { border-color:#1a2a3a; color:#8ab6e0; }
+  .acts .im.mailbtn:hover, td.acts-td .im.mailbtn:hover { border-color:#8ab6e0; }
+  .mailpop { margin-top:2px; padding:7px 8px; border-radius:5px; background:#0c1218; border:1px solid #1a2a3a;
+    font-size:calc(10.5px*var(--fs)); display:flex; flex-direction:column; gap:4px; }
+  .mailpop[hidden] { display:none; }
+  .mailpop label { color:var(--mut); font-size:calc(9.5px*var(--fs)); letter-spacing:.04em; }
+  .mailpop input, .mailpop textarea { width:100%; background:var(--card); color:var(--txt); border:1px solid var(--line);
+    border-radius:5px; padding:5px 7px; font:inherit; font-size:calc(10.5px*var(--fs)); }
+  .mailpop textarea { min-height:120px; resize:vertical; line-height:1.45; }
+  .mailpop .mrow { display:flex; gap:5px; margin-top:2px; }
+  .mailpop .mgo { flex:1; padding:5px; border-radius:5px; border:1px solid #1a2a3a; background:transparent; color:#8ab6e0;
+    font-family:inherit; font-weight:600; cursor:pointer; font-size:calc(10.5px*var(--fs)); }
+  .mailpop .mgo:hover { border-color:#8ab6e0; }
+  .mailpop .mgo:disabled { opacity:.5; cursor:wait; }
+  .mailpop a.mgo { text-align:center; text-decoration:none; }
+  .mailpop .mstat { font-size:calc(10px*var(--fs)); line-height:1.4; }
+  .mailpop .mstat.ok { color:#7fc98a; } .mailpop .mstat.err { color:#e08b8b; }
   /* list-view compact link cluster */
   td.acts-td { white-space:nowrap; }
   td.acts-td a, td.acts-td .intbtn { display:inline-block; font-size:11px; font-weight:600; padding:3px 8px; border-radius:5px; text-decoration:none; border:1px solid var(--line); margin:0 4px 3px 0; background:transparent; color:var(--accent); cursor:pointer; font-family:inherit; }
@@ -724,7 +743,11 @@ function actButtonsHTML(r) {
   const push = r.fm_present
     ? `<button class="im pushbtn" type="button" data-sku="${esc(r.sku || '')}" title="Preview the FileMaker→Shopify update (name / mfr / width / specs) — dry-run">Update Shopify ⇪</button><div class="pushpop" hidden></div>`
     : '';
-  return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${push}${stockPopHTML(r.stock)}<div class="livepop" hidden></div></div>`;
+  // Email Vendor = the stock & price path EVERY vendor gets (Steve 2026-07-15: no dead-end
+  // buttons — live_capable vendors scrape, everyone can be emailed; also a confirmation path
+  // for the live ones). Opens a compose panel; "Create Gmail draft" NEVER auto-sends.
+  const mail = `<button class="im mailbtn" type="button" data-sku="${esc(r.sku || '')}" data-vendor="${esc(r.vendor || '')}" data-mfr="${esc(r.mfr_number || '')}" data-pat="${esc(r.title || r.pattern || '')}" title="Compose a stock & price inquiry to the vendor rep — saves a Gmail DRAFT, never auto-sends">Email Vendor ✉</button><div class="mailpop" hidden></div>`;
+  return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${mail}${push}${stockPopHTML(r.stock)}<div class="livepop" hidden></div></div>`;
 }
 
 // Delegated within an .acts container (which stops propagation so the card's own
@@ -736,6 +759,8 @@ function handleActsClick(e) {
   if (sb) { const pop = sb.closest('.intpanel').querySelector('.stockpop'); if (pop) pop.hidden = !pop.hidden; return; }
   const lb = e.target.closest('.livebtn');
   if (lb) { fireLiveCheck(lb); return; }
+  const mb = e.target.closest('.mailbtn');
+  if (mb) { toggleMailPop(mb); return; }
   const cb = e.target.closest('.commitbtn');
   if (cb) { fireCommit(cb); return; }
   const pb = e.target.closest('.pushbtn');
@@ -819,6 +844,101 @@ function fireLiveCheck(lb) {
     .finally(() => { lb.dataset.busy = ''; lb.textContent = orig; });
 }
 
+// ── Email Vendor — stock & price inquiry compose (EVERY vendor; Gmail DRAFT only) ──────
+// To: pre-fills from /api/vendor-contact (and is REMEMBERED on submit via the server upsert,
+// so contact coverage grows as it's used). Professional trade-tone body asks the VENDOR for
+// availability / lead time / THEIR current net-trade price — our cost data never appears.
+// House signature block = the one dw-pitch-followup ships (address + 1-888-373-4564).
+const INQ_SIGNATURE = 'Thank you,\n\nDesigner Wallcoverings\nsteve@designerwallcoverings.com · 1-888-373-4564\n15442 Ventura Blvd, #102, Sherman Oaks, CA 91403';
+function inquiryDefaults(d) { // d = { vendor, sku, mfr, pat } (all strings, possibly empty)
+  const ref = [d.mfr || d.sku, d.pat].filter(Boolean).join(' · ') || d.vendor || '';
+  const subject = `Stock & price check — ${ref} (Designer Wallcoverings)`;
+  const lines = ['Hello,', '',
+    'This is Designer Wallcoverings (trade account) — could you check current stock and pricing on the following item for us?', ''];
+  if (d.pat) lines.push(`  Pattern / colorway: ${d.pat}`);
+  if (d.mfr) lines.push(`  Mfr SKU: ${d.mfr}`);
+  if (d.sku && d.sku !== d.mfr) lines.push(`  Our ref: ${d.sku}`);
+  if (d.vendor) lines.push(`  Line: ${d.vendor}`);
+  lines.push('', 'Specifically:',
+    '  • Current stock / availability',
+    '  • Lead time if not in stock',
+    '  • Current net / trade price',
+    '', INQ_SIGNATURE);
+  return { subject, body: lines.join('\n') };
+}
+const CONTACT_CACHE = {};   // vendor → {email} (session cache so repeat opens don't refetch)
+function toggleMailPop(mb) {
+  const pop = mb.parentElement.querySelector('.mailpop');
+  if (!pop) return;
+  if (!pop.hidden) { pop.hidden = true; return; }
+  pop.hidden = false;
+  if (!pop.dataset.built) buildMailPop(pop, mb.dataset);
+}
+async function buildMailPop(pop, d) {
+  pop.dataset.built = '1';
+  const def = inquiryDefaults(d);
+  pop.innerHTML = `
+    <label>To${d.vendor ? ' — ' + esc(d.vendor) + ' rep' : ''}</label>
+    <input type="text" class="mto" placeholder="vendor rep email — will be remembered">
+    <label>Subject</label>
+    <input type="text" class="msub">
+    <label>Message</label>
+    <textarea class="mbody"></textarea>
+    <div class="mrow"><button type="button" class="mgo mdraft">Create Gmail draft</button><a class="mgo mmailto" target="_blank" rel="noopener noreferrer">Open in mail app</a></div>
+    <div class="mstat" hidden></div>`;
+  pop.querySelector('.msub').value = def.subject;
+  pop.querySelector('.mbody').value = def.body;
+  const syncMailto = () => {
+    pop.querySelector('.mmailto').href = 'mailto:' + encodeURIComponent(pop.querySelector('.mto').value.trim())
+      + '?subject=' + encodeURIComponent(pop.querySelector('.msub').value)
+      + '&body=' + encodeURIComponent(pop.querySelector('.mbody').value);
+  };
+  pop.querySelectorAll('input,textarea').forEach((el) => el.addEventListener('input', syncMailto));
+  syncMailto();
+  pop.querySelector('.mdraft').addEventListener('click', () => fireInquiry(pop, d));
+  // pre-fill the remembered rep email for this vendor (grows via the server upsert)
+  if (d.vendor) {
+    try {
+      const c = CONTACT_CACHE[d.vendor]
+        || (CONTACT_CACHE[d.vendor] = await (await fetch('/api/vendor-contact?vendor=' + encodeURIComponent(d.vendor))).json());
+      const to = pop.querySelector('.mto');
+      if (c && c.email && !to.value) { to.value = c.email; syncMailto(); }
+    } catch (e) { /* unknown contact — placeholder stands */ }
+  }
+}
+async function fireInquiry(pop, d) {
+  const btn = pop.querySelector('.mdraft'), stat = pop.querySelector('.mstat');
+  const to = pop.querySelector('.mto').value.trim();
+  const subject = pop.querySelector('.msub').value.trim();
+  const body = pop.querySelector('.mbody').value;
+  stat.hidden = false; stat.className = 'mstat';
+  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { stat.className = 'mstat err'; stat.textContent = 'Enter the vendor rep’s email address first.'; return; }
+  if (!subject.trim() || !body.trim()) { stat.className = 'mstat err'; stat.textContent = 'Subject and message are required.'; return; }
+  btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Drafting…';
+  stat.textContent = '◌ Saving contact + creating Gmail draft…';
+  try {
+    const r = await fetch('/api/vendor-inquiry', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ vendor: d.vendor || '', to, subject, body, sku: d.sku || '' }),
+    });
+    const j = await r.json();
+    if (j.drafted) {
+      stat.className = 'mstat ok';
+      stat.textContent = '✓ draft saved in Gmail (' + (j.account || 'steve-office') + ') — review + send from there. Contact remembered.';
+      if (d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
+    } else if (j.ok) {
+      stat.className = 'mstat err';
+      stat.textContent = (j.contact_saved ? 'Contact saved, but the ' : 'The ') + 'Gmail draft failed: '
+        + (j.reason || 'unknown') + ' — use “Open in mail app” instead.';
+      if (j.contact_saved && d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
+    } else {
+      stat.className = 'mstat err'; stat.textContent = j.reason || 'Request failed — try again.';
+    }
+  } catch (e) {
+    stat.className = 'mstat err'; stat.textContent = 'Request failed — try again.';
+  } finally { btn.disabled = false; btn.textContent = orig; }
+}
+
 // Mfr # chip — shows BOTH Shopify's stored number AND what FileMaker Pro shows, side by side,
 // with a ⚠ + tinted row when they disagree (the mfr-number verification workflow). FM value in
 // green when it agrees. When the SKU isn't in the WALLPAPER file, just the Shopify number shows.
diff --git a/public/microsites.html b/public/microsites.html
index 3a083e9..4172b7d 100644
--- a/public/microsites.html
+++ b/public/microsites.html
@@ -61,6 +61,29 @@
   .card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
   .card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
 
+  /* per-card vendor inquiry button + page-level compose modal (Gmail DRAFT only, never auto-sends) */
+  .card .mailv { margin-top:12px; align-self:flex-start; background:none; border:1px solid var(--line); border-radius:2px;
+    padding:7px 12px; font-size:10px; letter-spacing:.18em; text-transform:uppercase; color:var(--gold); cursor:pointer; }
+  .card .mailv:hover { border-color:var(--gold); }
+  #mailModal { position:fixed; inset:0; background:rgba(22,20,18,.55); display:none; align-items:center; justify-content:center; z-index:60; }
+  #mailModal.open { display:flex; }
+  #mailModal .mbox { background:#fff; border:1px solid var(--line); width:min(560px,94vw); max-height:88vh; overflow:auto;
+    padding:26px 28px; display:flex; flex-direction:column; gap:8px; }
+  #mailModal h3 { font-family:Georgia,serif; font-weight:400; font-size:19px; letter-spacing:.03em; }
+  #mailModal label { font-size:10px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; margin-top:6px; }
+  #mailModal input, #mailModal textarea { width:100%; border:1px solid var(--line); background:#fdfcfa; padding:9px 11px;
+    font:inherit; font-size:13px; border-radius:2px; }
+  #mailModal textarea { min-height:180px; resize:vertical; line-height:1.5; }
+  #mailModal .mrow { display:flex; gap:10px; margin-top:10px; }
+  #mailModal .mgo { flex:1; padding:10px; border:1px solid var(--ink); background:var(--ink); color:#f2eee6;
+    font-size:11px; letter-spacing:.18em; text-transform:uppercase; cursor:pointer; text-align:center; text-decoration:none; }
+  #mailModal a.mgo { background:none; color:var(--ink); }
+  #mailModal .mgo:hover { border-color:var(--gold); }
+  #mailModal .mgo:disabled { opacity:.5; cursor:wait; }
+  #mailModal .mstat { font-size:12px; margin-top:6px; }
+  #mailModal .mstat.ok { color:#3d7a4a; } #mailModal .mstat.err { color:#a03c3c; }
+  #mailModal .mclose { align-self:flex-end; background:none; border:none; font-size:22px; cursor:pointer; color:#9a927f; }
+
   .sentinel { height: 10px; }
   footer { padding: 44px 34px 60px; text-align:center; font-size:11px; letter-spacing:.2em; text-transform:uppercase; color:#9a927f; border-top:1px solid var(--line); }
   @media (max-width: 900px){ .grid{ --cols: 2 !important; } .controls{ top:63px } }
@@ -117,6 +140,25 @@
 <main class="grid" id="grid"></main>
 <div class="sentinel" id="sentinel"></div>
 
+<!-- vendor-level stock & price inquiry — creates a Gmail DRAFT via George; never auto-sends -->
+<div id="mailModal">
+  <div class="mbox">
+    <button class="mclose" aria-label="Close" onclick="mailModal.classList.remove('open')">&times;</button>
+    <h3 id="mmTitle">Stock &amp; Price Inquiry</h3>
+    <label>To — vendor rep email</label>
+    <input type="text" id="mmTo" placeholder="vendor rep email — will be remembered">
+    <label>Subject</label>
+    <input type="text" id="mmSub">
+    <label>Message</label>
+    <textarea id="mmBody"></textarea>
+    <div class="mrow">
+      <button type="button" class="mgo" id="mmDraft">Create Gmail Draft</button>
+      <a class="mgo" id="mmMailto" target="_blank" rel="noopener noreferrer">Open in Mail App</a>
+    </div>
+    <div class="mstat" id="mmStat" hidden></div>
+  </div>
+</div>
+
 <footer>Designer Wallcoverings &amp; Fabrics — To The Trade &amp; Beyond</footer>
 
 <script>
@@ -217,11 +259,64 @@ function card(s){
       <h3></h3>
       <div class="sub">${sub}</div>
       <div class="cta">Visit Microsite →</div>
+      <button type="button" class="mailv" title="Compose a stock &amp; price inquiry to this vendor — saves a Gmail DRAFT, never auto-sends">✉ Stock &amp; Price</button>
     </div>`;
   el.querySelector('h3').textContent = name;
+  // The card itself is an <a> — the inquiry button must not navigate.
+  el.querySelector('.mailv').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openMail(name); });
   return el;
 }
 
+// ── vendor-level stock & price inquiry (every vendor card — Steve 2026-07-15 rule) ──
+// Pre-fills the remembered rep email from /api/vendor-contact; "Create Gmail Draft" posts
+// /api/vendor-inquiry (upserts the contact + drafts via George — NEVER an auto-send).
+const mailModal = document.getElementById('mailModal');
+const INQ_SIG = 'Thank you,\n\nDesigner Wallcoverings\nsteve@designerwallcoverings.com · 1-888-373-4564\n15442 Ventura Blvd, #102, Sherman Oaks, CA 91403';
+const CONTACTS = {};
+let mmVendor = '';
+const mmTo = document.getElementById('mmTo'), mmSub = document.getElementById('mmSub'),
+      mmBody = document.getElementById('mmBody'), mmStat = document.getElementById('mmStat'),
+      mmDraft = document.getElementById('mmDraft'), mmMailto = document.getElementById('mmMailto');
+function mmSync(){ mmMailto.href = 'mailto:' + encodeURIComponent(mmTo.value.trim())
+  + '?subject=' + encodeURIComponent(mmSub.value) + '&body=' + encodeURIComponent(mmBody.value); }
+[mmTo, mmSub, mmBody].forEach(el => el.addEventListener('input', mmSync));
+async function openMail(vendor){
+  mmVendor = vendor;
+  document.getElementById('mmTitle').textContent = 'Stock & Price Inquiry — ' + vendor;
+  mmTo.value = ''; mmStat.hidden = true; mmStat.textContent = '';
+  mmSub.value = `Stock & price check — ${vendor} line (Designer Wallcoverings)`;
+  mmBody.value = ['Hello,', '',
+    `This is Designer Wallcoverings (trade account) — we'd like to confirm current stock and pricing on your ${vendor} line.`, '',
+    'Specifically:',
+    '  • Current stock / availability',
+    '  • Lead time if not in stock',
+    '  • Current net / trade price',
+    '', INQ_SIG].join('\n');
+  mailModal.classList.add('open');
+  mmSync();
+  try {
+    const c = CONTACTS[vendor] || (CONTACTS[vendor] = await (await fetch('/api/vendor-contact?vendor=' + encodeURIComponent(vendor))).json());
+    if (c && c.email && mmVendor === vendor && !mmTo.value) { mmTo.value = c.email; mmSync(); }
+  } catch(e) { /* unknown contact — placeholder stands */ }
+}
+mailModal.addEventListener('click', (e) => { if (e.target === mailModal) mailModal.classList.remove('open'); });
+mmDraft.addEventListener('click', async () => {
+  const to = mmTo.value.trim();
+  mmStat.hidden = false; mmStat.className = 'mstat';
+  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { mmStat.className = 'mstat err'; mmStat.textContent = 'Enter the vendor rep’s email address first.'; return; }
+  mmDraft.disabled = true; const orig = mmDraft.textContent; mmDraft.textContent = 'Drafting…';
+  mmStat.textContent = '◌ Saving contact + creating Gmail draft…';
+  try {
+    const r = await fetch('/api/vendor-inquiry', { method:'POST', headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({ vendor: mmVendor, to, subject: mmSub.value.trim(), body: mmBody.value, sku: '' }) });
+    const j = await r.json();
+    if (j.drafted) { mmStat.className = 'mstat ok'; mmStat.textContent = '✓ draft saved in Gmail (' + (j.account||'steve-office') + ') — review + send from there. Contact remembered.'; CONTACTS[mmVendor] = { email: to }; }
+    else if (j.ok) { mmStat.className = 'mstat err'; mmStat.textContent = (j.contact_saved ? 'Contact saved, but the ' : 'The ') + 'Gmail draft failed: ' + (j.reason||'unknown') + ' — use "Open in Mail App" instead.'; if (j.contact_saved) CONTACTS[mmVendor] = { email: to }; }
+    else { mmStat.className = 'mstat err'; mmStat.textContent = j.reason || 'Request failed — try again.'; }
+  } catch(e) { mmStat.className = 'mstat err'; mmStat.textContent = 'Request failed — try again.'; }
+  finally { mmDraft.disabled = false; mmDraft.textContent = orig; }
+});
+
 function renderMore(){
   const next = state.view.slice(state.rendered, state.rendered + state.BATCH);
   next.forEach(s=>grid.appendChild(card(s)));
diff --git a/server.js b/server.js
index e343a36..6b55595 100644
--- a/server.js
+++ b/server.js
@@ -1149,6 +1149,119 @@ async function handleSimilar(req, res, u) {
   });
 }
 
+// ── Vendor stock & price INQUIRY (the email path) ────────────────────────────────
+// Steve's standing rule (2026-07-15): EVERY display vendor gets a working "check stock
+// and price" action. live_capable vendors scrape their portal; everyone else (and live
+// ones too, as a confirmation path) gets an EMAIL to the vendor rep — composed here,
+// saved as a Gmail DRAFT via George. DRAFT ONLY, never an auto-send ($0, reversible).
+//   GET  /api/vendor-contact?vendor=<name>   → stored rep email + last draft (case-insens.)
+//   POST /api/vendor-inquiry {vendor,to,cc?,subject,body,sku}
+//        → UPSERTs vendor_contacts (coverage grows organically as Steve uses it), then
+//          creates the Gmail draft. George unreachable/unconfigured → the contact still
+//          saves and {drafted:false, reason, mailto} lets the UI fall back to mailto:.
+// LEAK-SAFE: the payload carries only what the caller typed — no cost/net/wholesale ever
+// enters the compose (the whole point is asking the VENDOR for THEIR current price).
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const GEORGE_ENV = process.env.GEORGE_ENV_PATH || path.join(os.homedir(), 'Projects', 'george-gmail', '.env');
+const SECRETS_ENV = process.env.SECRETS_ENV_PATH || path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
+// Drafts land in the steve-office (steve@designerwallcoverings.com) Gmail by default;
+// override with GEORGE_DRAFT_ACCOUNT=info to draft from info@ instead.
+const GEORGE_DRAFT_ACCOUNT = process.env.GEORGE_DRAFT_ACCOUNT || 'steve-office';
+function envFrom(file, key) {
+  try { const m = fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : ''; } catch { return ''; }
+}
+// Same resolution order dw-pitch-followup uses: canonical GEORGE_AUTH ("user:pass") from
+// process.env → secrets-manager master → george-gmail/.env → legacy PASS-only key.
+// Null (no cred found) is fine — the inquiry degrades to drafted:false + mailto.
+function resolveGeorgeAuth() {
+  const direct = process.env.GEORGE_AUTH || envFrom(SECRETS_ENV, 'GEORGE_AUTH') || envFrom(GEORGE_ENV, 'GEORGE_AUTH');
+  if (direct) return direct.startsWith('Basic ') ? direct : ('Basic ' + Buffer.from(direct.includes(':') ? direct : ('admin:' + direct)).toString('base64'));
+  const pass = process.env.GEORGE_BASIC_AUTH_PASS || envFrom(GEORGE_ENV, 'GEORGE_BASIC_AUTH_PASS');
+  return pass ? 'Basic ' + Buffer.from('admin:' + pass).toString('base64') : null;
+}
+const GEORGE_AUTH = resolveGeorgeAuth();
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const mailtoUrl = (to, subject, body) =>
+  `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+
+async function handleVendorContact(req, res, u) {
+  const vendor = (u.searchParams.get('vendor') || '').trim();
+  if (!vendor) return sendJSON(res, 400, { ok: false, reason: 'vendor required' });
+  if (!pool) return sendJSON(res, 503, { ok: false, reason: 'db not ready' });
+  try {
+    const { rows } = await pool.query(
+      `SELECT vendor, email, draft_subject, draft_body FROM vendor_contacts
+        WHERE lower(vendor) = lower($1) LIMIT 1`, [vendor]);
+    const r = rows[0] || null;
+    return sendJSON(res, 200, {
+      ok: true, vendor, known: !!r,
+      email: (r && r.email) ? r.email : null,
+      draft_subject: r ? (r.draft_subject || null) : null,
+      draft_body: r ? (r.draft_body || null) : null,
+    });
+  } catch (e) { return sendJSON(res, 500, { ok: false, reason: e.message }); }
+}
+
+async function handleVendorInquiry(req, res) {
+  let b = {};
+  try { b = JSON.parse(await readBody(req) || '{}'); } catch { /* bad body → field checks reject below */ }
+  const vendor = String(b.vendor || '').trim();
+  const to = String(b.to || '').trim();
+  const cc = String(b.cc || '').trim();
+  const subject = String(b.subject || '').trim();
+  const body = String(b.body || '').trim();
+  const sku = String(b.sku || '').trim();
+  if (!vendor) return sendJSON(res, 400, { ok: false, reason: 'vendor required' });
+  if (!EMAIL_RE.test(to)) return sendJSON(res, 400, { ok: false, reason: 'to= must be a valid email address' });
+  if (cc && !EMAIL_RE.test(cc)) return sendJSON(res, 400, { ok: false, reason: 'cc= must be a valid email address' });
+  if (!subject || !body) return sendJSON(res, 400, { ok: false, reason: 'subject and body required' });
+  const mailto = mailtoUrl(to, subject, body);
+
+  // 1. UPSERT the contact — coverage grows organically as Steve uses the compose. Update is
+  //    case-insensitive against the existing vendor key so casing variants never dupe rows.
+  let saved = false;
+  if (pool) {
+    try {
+      const upd = await pool.query(
+        `UPDATE vendor_contacts SET email=$2, draft_subject=$3, draft_body=$4,
+                draft_saved_at=now(), updated_at=now()
+          WHERE lower(vendor) = lower($1)`, [vendor, to, subject, body]);
+      if (upd.rowCount === 0) {
+        await pool.query(
+          `INSERT INTO vendor_contacts (vendor, email, draft_subject, draft_body, draft_saved_at, updated_at)
+           VALUES ($1, $2, $3, $4, now(), now())`, [vendor, to, subject, body]);
+      }
+      saved = true;
+    } catch (e) { console.error('vendor_contacts upsert failed (non-fatal):', e.message); }
+  }
+
+  // 2. Gmail DRAFT via George — POST /api/drafts (NEVER /api/send). Any failure degrades
+  //    gracefully: the contact is already saved and the UI gets the mailto: fallback.
+  if (!GEORGE_AUTH) {
+    return sendJSON(res, 200, { ok: true, drafted: false, contact_saved: saved,
+      reason: 'George credentials not configured on this host', mailto });
+  }
+  try {
+    const r = await fetch(`${GEORGE}/api/drafts`, {
+      method: 'POST', signal: AbortSignal.timeout(12000),
+      headers: { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH },
+      body: JSON.stringify({ to, ...(cc ? { cc } : {}), subject, body,
+        account: GEORGE_DRAFT_ACCOUNT, source: 'all-dw vendor inquiry' + (sku ? ' · ' + sku : '') }),
+    });
+    const d = await r.json().catch(() => ({}));
+    if (r.status === 200 && d.success) {
+      console.log(`vendor-inquiry: Gmail draft ${d.draftId} → ${to} (${vendor}${sku ? ' · ' + sku : ''}) [$0 — draft only, never sent]`);
+      return sendJSON(res, 200, { ok: true, drafted: true, gmail_draft_id: d.draftId,
+        account: d.account || GEORGE_DRAFT_ACCOUNT, contact_saved: saved, mailto });
+    }
+    return sendJSON(res, 200, { ok: true, drafted: false, contact_saved: saved,
+      reason: (d && d.error) || `George HTTP ${r.status}`, mailto });
+  } catch (e) {
+    return sendJSON(res, 200, { ok: true, drafted: false, contact_saved: saved,
+      reason: 'George unreachable: ' + e.message, mailto });
+  }
+}
+
 // Basic Auth gate (Steve 2026-07-02: all. is published but un/pw-gated).
 // Override with BASIC_AUTH=user:pass (or BASIC_AUTH_USER / BASIC_AUTH_PASS) in .env;
 // default stays admin:DW2024! so behavior is byte-identical when unset. /healthz open.
@@ -1239,6 +1352,8 @@ const server = http.createServer((req, res) => {
   if (u.pathname === '/api/discontinued') { handleDiscontinued(req, res, u); return; }
   if (u.pathname === '/api/similar') { handleSimilar(req, res, u); return; }
   if (u.pathname === '/api/livestock') { handleLiveStock(req, res, u); return; }
+  if (u.pathname === '/api/vendor-contact') { handleVendorContact(req, res, u); return; }
+  if (u.pathname === '/api/vendor-inquiry' && req.method === 'POST') { handleVendorInquiry(req, res); return; }
   if (u.pathname === '/api/shopify-push') { handleShopifyPush(req, res, u); return; }
   if (u.pathname === '/api/shopify-push-bulk') { handleShopifyPushBulk(req, res); return; }
 

← 9317a40 chore: v1.5.0 (session close — pm2 viewer auto-discovery + /  ·  back to All Designerwallcoverings  ·  chore: vendor_contacts self-heal DDL, microsites compose val 016fb39 →