← back to Dw Pitch Followup
pitch: Details drawer shows full email history with each client
a4bc2e8f5e619718e6c11b5f4914cc60f094020a · 2026-07-15 09:48:27 -0700 · Steve
New /api/emails?email= (George read-only) returns all emails to/from a client across info@ + steve-office, deduped, newest first, with in/out direction from the From header. Drawer renders the thread list (direction chip · date · subject · snippet), lazy-loaded on open alongside last-corresponded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit a4bc2e8f5e619718e6c11b5f4914cc60f094020a
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 09:48:27 2026 -0700
pitch: Details drawer shows full email history with each client
New /api/emails?email= (George read-only) returns all emails to/from a client across info@ + steve-office, deduped, newest first, with in/out direction from the From header. Drawer renders the thread list (direction chip · date · subject · snippet), lazy-loaded on open alongside last-corresponded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
public/index.html | 23 ++++++++++++++++++++++-
server.js | 36 ++++++++++++++++++++++++++++++++++++
2 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index 0e9aab8..46bfb58 100644
--- a/public/index.html
+++ b/public/index.html
@@ -42,6 +42,11 @@
.pgallery a{color:var(--gold);text-decoration:none;font-size:13px}
.pgallery a:hover{text-decoration:underline}
.pgallery .pg-name{font-size:13px;color:var(--ink)}
+ .emails{margin-top:6px;display:flex;flex-direction:column;gap:7px;max-height:340px;overflow-y:auto}
+ .emails .emcount{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
+ .emails .em{border-left:2px solid var(--line);padding:2px 0 3px 9px;font-size:12.5px;line-height:1.4}
+ .emails .em .emdate{color:var(--mut)}
+ .emails .em .emsnip{color:var(--mut);font-size:11.5px;margin-top:2px}
.reorder-note{color:var(--red);font-weight:600;margin:6px 0}
.spacer{flex:1}
.selcount{color:var(--gold);font-size:13px}
@@ -385,6 +390,7 @@ function panelContextHTML(list,r){
if(list!=='list3') html+=merchBlock(r);
html+=samplesBlock(r);
html+=`<div class="drow lastcorr" data-lastcorr>📨 Last corresponded: <span data-lc>${(list==='list3')?'—':'click to load…'}</span></div>`;
+ if(list!=='list3') html+=`<div class="drow"><b>📧 All emails with this client</b><div class="emails" data-emails>loading…</div></div>`;
return html;
}
function openPanel(){document.getElementById('panel').classList.add('open');document.body.classList.add('panel-open')}
@@ -413,7 +419,7 @@ function openPanelFor(card,{generate=false}={}){
if(reorder){ document.getElementById('pSubj').innerHTML='<span class="reorder-note">Reorder Samples?? — nothing has shipped yet, so there’s nothing to follow up on.</span>'; }
openPanel();
renderLetterGallery(r);
- if(list!=='list3') loadLastCorr(r);
+ if(list!=='list3'){ loadLastCorr(r); loadEmails(r); }
if(generate && !reorder) composeLetter({});
}
// Small product strip under the letter: thumbnail + link to the DW product page for each
@@ -445,6 +451,21 @@ function paintLastCorr(wrap,span,val){
wrap.classList.add('found');
span.innerHTML=`${esc(fmtWhen(val.date))}${val.subject?` · "${esc(val.subject.slice(0,60))}"`:''}${val.account?` <span class="meta">(${esc(val.account)})</span>`:''}`;
}
+// Full email thread history with this client (both directions), loaded when the drawer opens.
+async function loadEmails(r){
+ const el=document.querySelector('#pCtx [data-emails]'); if(!el)return;
+ const email=(r.email||'').trim(); if(!email){el.textContent='no email on file for this client';return}
+ el.textContent='loading emails…';
+ try{
+ const j=await (await fetch(API('/api/emails?email='+encodeURIComponent(email)+'&limit=40'))).json();
+ const ems=j.emails||[];
+ if(!ems.length){el.textContent='no emails found with this client';return}
+ el.innerHTML=`<div class="emcount">${ems.length} email${ems.length===1?'':'s'} (newest first)</div>`+ems.map(m=>{
+ const inb=m.dir==='in';
+ return `<div class="em"><span class="chip ${inb?'g':'a'}" title="${inb?'from client':'we sent'}">${inb?'← in':'→ out'}</span> <span class="emdate">${esc(fmtWhen(m.date))}</span>${m.subject?` · <b>${esc(m.subject.slice(0,80))}</b>`:''}${m.snippet?`<div class="emsnip">${esc(m.snippet)}</div>`:''}</div>`;
+ }).join('');
+ }catch(e){el.textContent='(email lookup failed)'}
+}
async function composeLetter(opts={}){
if(!active)return; const {row:r,list}=active;
diff --git a/server.js b/server.js
index ba37c53..8dba228 100644
--- a/server.js
+++ b/server.js
@@ -463,6 +463,42 @@ app.get('/api/lastcorr', async (req, res) => {
}
});
+// ALL emails exchanged with a client address (both directions), newest first — George read-only.
+// Deduped across the info@ + steve-office mailboxes; direction inferred from the From header.
+async function georgeEmails(email, limit = 25) {
+ if (!email) return [];
+ const q = encodeURIComponent(`from:${email} OR to:${email}`);
+ const out = [], seen = new Set(), lc = String(email).toLowerCase();
+ for (const account of ['info', 'steve-office']) {
+ try {
+ const r = await fetch(`${GEORGE}/api/search?q=${q}&account=${account}&maxResults=${limit}`, {
+ headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(10000),
+ });
+ if (!r.ok) continue;
+ const j = await r.json();
+ for (const m of (j.messages || [])) {
+ if (!m.id || seen.has(m.id)) continue; seen.add(m.id);
+ const from = String(m.from || '');
+ out.push({ id: m.id, date: m.date || '', subject: m.subject || '', snippet: String(m.snippet || '').slice(0, 180), from, mailbox: account, dir: from.toLowerCase().includes(lc) ? 'in' : 'out' });
+ }
+ } catch { /* try next mailbox */ }
+ }
+ out.sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0));
+ return out.slice(0, limit);
+}
+
+// GET /api/emails?email= — full recent email history with one client (lazy, per-drawer).
+app.get('/api/emails', async (req, res) => {
+ try {
+ const email = String(req.query.email || '').trim();
+ if (!email) return res.json({ email: '', count: 0, emails: [] });
+ const emails = await georgeEmails(email, Math.min(50, Number(req.query.limit) || 25));
+ res.json({ email, count: emails.length, emails });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// POST /api/send — actually EMAIL a generated letter, FROM info@designerwallcoverings.com,
// via George's /api/send. Best-effort: reply into the client's last info@ thread so it lands
// as a follow-up, not a cold email. George applies its own external-send guard; if it blocks,
← 71574cf 5x: 2 clean sweeps (7/7 six-way + 51 controls, 0 defects) af
·
back to Dw Pitch Followup
·
pitch: email history collapsed by default, lazy-loads on exp e592d47 →