[object Object]

← back to Dw Pitch Followup

pitch: click any email in the history to expand its full body

e039b5ad6fd16ce3f37b732e747e7f955dc5837e · 2026-07-15 10:01:50 -0700 · Steve

New /api/email?id=&account= fetches the full message via George and strips HTML to readable text. Each email row is now clickable → lazy-loads + expands the body inline (caret ▸/▾), collapses on re-click, cached per message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit e039b5ad6fd16ce3f37b732e747e7f955dc5837e
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 15 10:01:50 2026 -0700

    pitch: click any email in the history to expand its full body
    
    New /api/email?id=&account= fetches the full message via George and strips HTML to readable text. Each email row is now clickable → lazy-loads + expands the body inline (caret ▸/▾), collapses on re-click, cached per message.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/index.html | 40 +++++++++++++++++++++++++++++++++-------
 server.js         | 23 +++++++++++++++++++++++
 2 files changed, 56 insertions(+), 7 deletions(-)

diff --git a/public/index.html b/public/index.html
index 4bd79cd..4e57e0c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -49,8 +49,13 @@
   .emails[hidden]{display:none}
   .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 .emrow{cursor:pointer;user-select:none}
+  .emails .em .emrow:hover{color:var(--gold)}
+  .emails .em .emrow .emcaret{color:var(--mut);font-size:10px}
   .emails .em .emdate{color:var(--mut)}
   .emails .em .emsnip{color:var(--mut);font-size:11.5px;margin-top:2px}
+  .emails .em .embody{white-space:pre-wrap;font-size:12px;line-height:1.5;color:var(--ink);background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:8px 10px;margin-top:5px;max-height:320px;overflow-y:auto}
+  .emails .em .embody[hidden]{display:none}
   .reorder-note{color:var(--red);font-weight:600;margin:6px 0}
   .spacer{flex:1}
   .selcount{color:var(--gold);font-size:13px}
@@ -465,9 +470,13 @@ async function loadEmails(r){
     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=>{
+    el.innerHTML=`<div class="emcount">${ems.length} email${ems.length===1?'':'s'} (newest first) · click one to read it</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>`;
+      return `<div class="em" data-emid="${esc(m.id)}" data-mbx="${esc(m.mailbox||'info')}">`
+        +`<div class="emrow"><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>`:''} <span class="emcaret">▸</span></div>`
+        +`${m.snippet?`<div class="emsnip">${esc(m.snippet)}</div>`:''}`
+        +`<div class="embody" hidden></div>`
+        +`</div>`;
     }).join('');
   }catch(e){el.textContent='(email lookup failed)'}
 }
@@ -594,11 +603,28 @@ document.getElementById('sentBtn').addEventListener('click',openSentLog);
 document.getElementById('pClose').addEventListener('click',()=>{document.body.classList.remove('log-mode');closePanel();});
 // Expand/collapse the client's email history — lazy-load on first expand (collapsed by default).
 document.getElementById('pCtx').addEventListener('click',e=>{
-  const t=e.target.closest('[data-emtoggle]'); if(!t)return;
-  const box=t.parentElement.querySelector('[data-emails]'); if(!box)return;
-  const open=box.hasAttribute('hidden');
-  if(open){ box.removeAttribute('hidden'); t.querySelector('.emcaret').textContent='▾'; if(active&&active.row)loadEmails(active.row); }
-  else { box.setAttribute('hidden',''); t.querySelector('.emcaret').textContent='▸'; }
+  // 1) expand/collapse the whole email-history section
+  const t=e.target.closest('[data-emtoggle]');
+  if(t){
+    const box=t.parentElement.querySelector('[data-emails]'); if(!box)return;
+    const open=box.hasAttribute('hidden');
+    if(open){ box.removeAttribute('hidden'); t.querySelector('.emcaret').textContent='▾'; if(active&&active.row)loadEmails(active.row); }
+    else { box.setAttribute('hidden',''); t.querySelector('.emcaret').textContent='▸'; }
+    return;
+  }
+  // 2) expand/collapse an individual email → lazy-load its full body
+  const row=e.target.closest('.emrow');
+  if(row){
+    const em=row.closest('.em'), body=em.querySelector('.embody'), caret=row.querySelector('.emcaret');
+    if(body.hasAttribute('hidden')){
+      body.removeAttribute('hidden'); if(caret)caret.textContent='▾';
+      if(!body.dataset.loaded){ body.dataset.loaded='1'; body.textContent='loading…';
+        fetch(API('/api/email?id='+encodeURIComponent(em.dataset.emid)+'&account='+encodeURIComponent(em.dataset.mbx||'info')))
+          .then(r=>r.json()).then(j=>{ body.textContent=(j.text&&j.text.trim())?j.text:'(no readable body)'; })
+          .catch(()=>{ body.textContent='(failed to load email)'; body.dataset.loaded=''; });
+      }
+    } else { body.setAttribute('hidden',''); if(caret)caret.textContent='▸'; }
+  }
 });
 document.getElementById('pGen').addEventListener('click',()=>composeLetter({}));
 document.getElementById('pRegen').addEventListener('click',()=>composeLetter({regenerate:true}));
diff --git a/server.js b/server.js
index 8dba228..9b8cb8f 100644
--- a/server.js
+++ b/server.js
@@ -499,6 +499,29 @@ app.get('/api/emails', async (req, res) => {
   }
 });
 
+// GET /api/email?id=&account= — one full message, HTML stripped to readable text (expand-in-place).
+app.get('/api/email', async (req, res) => {
+  try {
+    const id = String(req.query.id || '').trim();
+    const account = String(req.query.account || 'info').trim();
+    if (!id) return res.status(400).json({ error: 'id required' });
+    const r = await fetch(`${GEORGE}/api/messages/${encodeURIComponent(id)}?account=${encodeURIComponent(account)}`, {
+      headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(12000),
+    });
+    if (!r.ok) return res.status(r.status).json({ error: `George ${r.status}` });
+    const m = await r.json();
+    const text = String(m.body || m.snippet || '')
+      .replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<script[\s\S]*?<\/script>/gi, ' ')
+      .replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n')
+      .replace(/<[^>]+>/g, '')
+      .replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&#39;|&apos;/gi, "'").replace(/&quot;/gi, '"')
+      .replace(/\n{3,}/g, '\n\n').replace(/[ \t]{2,}/g, ' ').trim();
+    res.json({ id, subject: m.subject || '', from: m.from || '', to: m.to || '', date: m.date || '', text: text.slice(0, 8000) });
+  } 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,

← 8f32c4b pitch: add 3x/day launchd rebuild so the board never goes st  ·  back to Dw Pitch Followup  ·  pitch: auto follow-up triage — reads the email thread → Foll 8e921dc →