← 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
M public/index.htmlM server.js
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(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/'|'/gi, "'").replace(/"/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 →