[object Object]

← back to Sample Followup Sweep

Reply loop: poll info@ for vendor replies + 3-business-day one-click resend queue

6a7ae10d34ff596d9eaff5153417966a862736b8 · 2026-08-17 08:10:58 -0700 · Steve Abrams

- scripts/poll-replies.mjs: George /api/info/search per sent vendor; records data/replies.json (reply snippet for Priority Memo Notes summary) + data/resend-queue.json (>=3 business days, weekends excluded, no reply). Read-only vs mail; never sends; skips queuing if George unreachable.
- server.js: /api/state adds resend+replies; /api/note POST -> fmpro.mjs note (one-click Priority Memo Notes); console shows 'replied'/'resend due' badges.
- Live poll: 15 replies detected, 0 resend-due (correct, ~1 biz day elapsed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6a7ae10d34ff596d9eaff5153417966a862736b8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 17 08:10:58 2026 -0700

    Reply loop: poll info@ for vendor replies + 3-business-day one-click resend queue
    
    - scripts/poll-replies.mjs: George /api/info/search per sent vendor; records data/replies.json (reply snippet for Priority Memo Notes summary) + data/resend-queue.json (>=3 business days, weekends excluded, no reply). Read-only vs mail; never sends; skips queuing if George unreachable.
    - server.js: /api/state adds resend+replies; /api/note POST -> fmpro.mjs note (one-click Priority Memo Notes); console shows 'replied'/'resend due' badges.
    - Live poll: 15 replies detected, 0 resend-due (correct, ~1 biz day elapsed).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 data/replies.json        | 124 +++++++++++++++++++++++++++++++++++++++++++++++
 data/resend-queue.json   |   5 ++
 scripts/poll-replies.mjs | 124 +++++++++++++++++++++++++++++++++++++++++++++++
 server.js                |  25 ++++++++--
 4 files changed, 275 insertions(+), 3 deletions(-)

diff --git a/data/replies.json b/data/replies.json
new file mode 100644
index 0000000..7d8d484
--- /dev/null
+++ b/data/replies.json
@@ -0,0 +1,124 @@
+{
+  "byVid": {
+    "GREEN": {
+      "replied": true,
+      "email": "sales@bmwallpaper.com",
+      "snippet": "Hello , Please check attached PI for your samples. Please note that your complimentary sample allowance for this year was fully utilized by the end of July. According to our sample policy, any",
+      "messageId": "1a00ec043ec1970c",
+      "foundAt": "2026-08-17T15:09:55.774Z",
+      "lastSent": "2026-08-15T19:01:31.254Z"
+    },
+    "KRA": {
+      "replied": true,
+      "email": "matt.schoffman@kravet.com",
+      "snippet": "2019101.34.0 &amp; 2019101.313.0 UPS 1Z7840E80395539882 DELIVERED 7/28 *Signed for by Marcus* F1562/04.CAC.0 &amp; F1562/02.CAC.0 USPS 9200190114065008315106 DELIVERED 8/4 GWP-3415.16.0 UPS",
+      "messageId": "1a006585e3101058",
+      "foundAt": "2026-08-17T15:09:56.299Z",
+      "lastSent": "2026-08-14T23:36:45.000Z"
+    },
+    "THI": {
+      "replied": true,
+      "email": "cathy.dy@thibautdesign.com",
+      "snippet": "Good Morning Marcus! These samples have been ordered on Order #1022017 to ship via FedEx 2nd Day Air 😊 Cathy Dy Sales Representative San Luis Obispo, Santa Barbara, San Fernando Valley, Pasadena, San",
+      "messageId": "1a0101d0c7927273",
+      "foundAt": "2026-08-17T15:09:56.902Z",
+      "lastSent": "2026-08-15T22:32:29.869Z"
+    },
+    "PF": {
+      "replied": true,
+      "email": "mocampo@kneedlerfauchere.com",
+      "snippet": "Hello, Please send your sample request directly to our Sample Department, as I do not handle sample requests. If you send your request directly to them, you will receive a confirmation as soon as",
+      "messageId": "1a01017919c2e567",
+      "foundAt": "2026-08-17T15:09:57.139Z",
+      "lastSent": "2026-08-15T22:32:11.497Z"
+    },
+    "ARTE INTERNATIONAL": {
+      "replied": true,
+      "email": "therese@egg-and-dart.com",
+      "snippet": "Tracking | UPS - United States 1Z9376YA1232212599 From: Therese Stachowiak Sent: Thursday, August 13, 2026 12:18 PM To: Designer Wallcoverings &lt;info@designerwallcoverings.com&gt; Subject: RE: New",
+      "messageId": "1a001cd1dd8b4aa3",
+      "foundAt": "2026-08-17T15:09:58.185Z",
+      "lastSent": "2026-08-14T23:38:26.000Z"
+    },
+    "SAND": {
+      "replied": true,
+      "email": "support@gimmersta.com",
+      "snippet": "Thank you for your purchase. Enclosed is your payment receipt Open receipt +46 33-20 80 00 | support@gimmersta.com Gimmersta Wallpaper US Corp. | 7 Corporate Center Court, Suite B | Greensboro, NC",
+      "messageId": "1a00fcad8359e40e",
+      "foundAt": "2026-08-17T15:09:58.512Z",
+      "lastSent": "2026-08-14T23:38:45.000Z"
+    },
+    "THIB": {
+      "replied": true,
+      "email": "cathy.dy@thibautdesign.com",
+      "snippet": "Good Morning Marcus! These samples have been ordered on Order #1022017 to ship via FedEx 2nd Day Air 😊 Cathy Dy Sales Representative San Luis Obispo, Santa Barbara, San Fernando Valley, Pasadena, San",
+      "messageId": "1a0101d0c7927273",
+      "foundAt": "2026-08-17T15:09:58.895Z",
+      "lastSent": "2026-08-15T22:32:29.869Z"
+    },
+    "SANDB": {
+      "replied": true,
+      "email": "support@gimmersta.com",
+      "snippet": "Thank you for your purchase. Enclosed is your payment receipt Open receipt +46 33-20 80 00 | support@gimmersta.com Gimmersta Wallpaper US Corp. | 7 Corporate Center Court, Suite B | Greensboro, NC",
+      "messageId": "1a00fcad8359e40e",
+      "foundAt": "2026-08-17T15:09:59.160Z",
+      "lastSent": "2026-08-14T23:38:45.000Z"
+    },
+    "ASTSAND": {
+      "replied": true,
+      "email": "amy.d@astek.com",
+      "snippet": "We don&#39;t have samples of wk111. The other one was sent out today Thank You! Amy Day Customer Service 15924 Arminta St Van Nuys, CA 91406 P 818.901.9876 xt:149 F 818.901.9891 www.astek.com @astekinc",
+      "messageId": "1a002b7d10135672",
+      "foundAt": "2026-08-17T15:09:59.421Z",
+      "lastSent": "2026-08-14T23:37:58.000Z"
+    },
+    "PIN": {
+      "replied": true,
+      "email": "losangelesshowroom@pindlercorp.com",
+      "snippet": "HI I Email those are not Pindler numbers KARAN SMITH LOS ANGELES SHOWROOM ASSISTANT P. 310.289.0200 | Corporate: 800.669.6002 losangeles@pindlercorp.com 8687 Melrose Ave., B-601 | Los Angeles, CA 90069",
+      "messageId": "1a002b08d3036b2c",
+      "foundAt": "2026-08-17T15:09:59.707Z",
+      "lastSent": "2026-08-14T23:38:08.000Z"
+    },
+    "JOFA": {
+      "replied": true,
+      "email": "matt.schoffman@kravet.com",
+      "snippet": "2019101.34.0 &amp; 2019101.313.0 UPS 1Z7840E80395539882 DELIVERED 7/28 *Signed for by Marcus* F1562/04.CAC.0 &amp; F1562/02.CAC.0 USPS 9200190114065008315106 DELIVERED 8/4 GWP-3415.16.0 UPS",
+      "messageId": "1a006585e3101058",
+      "foundAt": "2026-08-17T15:10:00.291Z",
+      "lastSent": "2026-08-14T23:36:45.000Z"
+    },
+    "PJ": {
+      "replied": true,
+      "email": "jballard@phillipjeffries.com",
+      "snippet": "Happy Friday! This item is discontinued with 24 yards left to purchase. No samples are available unfortunately. Apologies, perhaps I missed this in my email. -Joey From: Designer Wallcoverings &lt;info",
+      "messageId": "1a001dc621ea8b02",
+      "foundAt": "2026-08-17T15:10:00.617Z",
+      "lastSent": "2026-08-14T23:38:33.000Z"
+    },
+    "MAYA": {
+      "replied": true,
+      "email": "ari@quintushome.com",
+      "snippet": "Hello, Unfortunately, this colorway has been discontinued. Thank you, Ari Jackson Textiles &amp; Wallcovering Specialist 8687 Melrose Avenue · Suite B527 West Hollywood, CA 90069 O: 323-634-1924 www.",
+      "messageId": "1a002a8a2f321701",
+      "foundAt": "2026-08-17T15:10:01.296Z",
+      "lastSent": "2026-08-14T23:38:37.000Z"
+    },
+    "KOR": {
+      "replied": true,
+      "email": "samples@koroseal.com",
+      "snippet": "Hi, Just an FYI, Stephanie Shortino is not a sales representative any longer. Your sales rep is Adriana Reynoso, copied on this email. We have processed your sample request. Your order will ship today",
+      "messageId": "1a01040e2edfc4ca",
+      "foundAt": "2026-08-17T15:10:01.571Z",
+      "lastSent": "2026-08-14T23:38:40.000Z"
+    },
+    "RBL": {
+      "replied": true,
+      "email": "support@gimmersta.com",
+      "snippet": "Thank you for your purchase. Enclosed is your payment receipt Open receipt +46 33-20 80 00 | support@gimmersta.com Gimmersta Wallpaper US Corp. | 7 Corporate Center Court, Suite B | Greensboro, NC",
+      "messageId": "1a00fcad8359e40e",
+      "foundAt": "2026-08-17T15:10:01.821Z",
+      "lastSent": "2026-08-14T23:38:45.000Z"
+    }
+  }
+}
\ No newline at end of file
diff --git a/data/resend-queue.json b/data/resend-queue.json
new file mode 100644
index 0000000..d794f93
--- /dev/null
+++ b/data/resend-queue.json
@@ -0,0 +1,5 @@
+{
+  "generated": "2026-08-17T15:09:54.888Z",
+  "bizThreshold": 3,
+  "queue": []
+}
\ No newline at end of file
diff --git a/scripts/poll-replies.mjs b/scripts/poll-replies.mjs
new file mode 100644
index 0000000..4cb30b8
--- /dev/null
+++ b/scripts/poll-replies.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+// Reply poller + 3-business-day resend queue for the sample follow-up loop.
+//
+// For every already-sent vendor:
+//   1. Search the info@ mailbox (via George HTTP) for a reply FROM the vendor's
+//      sample email since the follow-up went out.
+//        • reply found  → record it in data/replies.json (snippet kept so a plain-language
+//                          "Priority Memo Notes" summary can be written to FileMaker).
+//        • no reply     → if ≥3 BUSINESS days have passed (weekends excluded), add the vendor
+//                          to data/resend-queue.json so the console can offer a ONE-CLICK resend.
+//
+// Read-only against mail; it never sends. The resend itself stays the human's one click
+// on the console (/api/send-one). Run on a schedule (launchd) or ad hoc:
+//   node scripts/poll-replies.mjs
+//   node scripts/poll-replies.mjs --biz 3     # override the business-day threshold
+
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import http from 'node:http';
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dir, '..');
+const GEORGE = { host: '127.0.0.1', port: 9850 };
+const BIZ_THRESHOLD = Number((process.argv.find((a, i) => process.argv[i - 1] === '--biz')) || 3);
+
+const readJSON = (f, d) => { try { return JSON.parse(readFileSync(join(ROOT, f), 'utf8')); } catch { return d; } };
+const norm = (s) => String(s || '').toLowerCase().trim();
+
+// --- George Basic auth, from the same .env files server.js reads ---
+function georgeAuth() {
+  const files = ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env'];
+  const genv = (k) => { for (const f of files) { try { const m = readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {} } return ''; };
+  let a = genv('GEORGE_BASIC_AUTH'); if (!a.includes(':')) a = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
+  return 'Basic ' + Buffer.from(a).toString('base64');
+}
+function georgeGet(path) {
+  return new Promise((resolve, reject) => {
+    const req = http.request({ ...GEORGE, path, method: 'GET', headers: { Authorization: georgeAuth() } }, (r) => {
+      let b = ''; r.on('data', (d) => b += d); r.on('end', () => { try { resolve(JSON.parse(b)); } catch { resolve(null); } });
+    });
+    req.on('error', reject); req.end();
+  });
+}
+
+// --- business-day math (weekends excluded) ---
+function businessDaysSince(iso, now = new Date()) {
+  const from = new Date(iso); let d = new Date(from); let n = 0;
+  while (true) {
+    d.setDate(d.getDate() + 1);
+    if (d > now) break;
+    const dow = d.getDay(); if (dow !== 0 && dow !== 6) n++;   // skip Sun(0)/Sat(6)
+  }
+  return n;
+}
+
+// --- sent-vendor roster (same logic as fmpro.mjs) ---
+function sentVendors() {
+  const fleet = readJSON('data/fleet.json', { vendors: [] }).vendors;
+  const contacts = readJSON('data/contacts.json', {});
+  const sent = readJSON('data/sent.json', { byEmail: {} }).byEmail || {};
+  const sentMap = {}; for (const [k, v] of Object.entries(sent)) sentMap[norm(k)] = v;
+  const out = [];
+  for (const v of fleet) {
+    const c = contacts[v.slug] || {};
+    if (c.disposition === 'no-chase') continue;
+    const addrs = norm(c.sample_email).split(',').map((a) => a.trim()).filter((a) => a.includes('@'));
+    const hits = addrs.map((a) => ({ a, s: sentMap[a] })).filter((x) => x.s);
+    if (!hits.length) continue;
+    const lastISO = hits.map((h) => h.s.lastSent).sort().pop();
+    out.push({ slug: v.slug, vid: v.vid, name: v.name, emails: addrs, lastSent: lastISO });
+  }
+  return out;
+}
+
+// --- did the vendor reply since we sent? (search info@ mailbox) ---
+async function findReply(emails, sinceISO) {
+  const since = new Date(sinceISO);
+  const dateQ = `${since.getFullYear()}/${since.getMonth() + 1}/${since.getDate()}`;
+  for (const email of emails) {
+    const q = encodeURIComponent(`from:${email} after:${dateQ}`);
+    const res = await georgeGet(`/api/info/search?q=${q}`).catch(() => null);
+    const msgs = res && (res.messages || res.results || (Array.isArray(res) ? res : null));
+    if (Array.isArray(msgs) && msgs.length) {
+      const m = msgs[0];
+      return { email, snippet: (m.snippet || m.subject || '').slice(0, 300), messageId: m.id || m.messageId || '' };
+    }
+  }
+  return null;
+}
+
+const run = async () => {
+  const vendors = sentVendors();
+  const replies = readJSON('data/replies.json', { byVid: {} });
+  const resend = { generated: new Date().toISOString(), bizThreshold: BIZ_THRESHOLD, queue: [] };
+  let georgeUp = true;
+  // probe George once
+  const probe = await georgeGet('/api/info/messages?maxResults=1').catch(() => null);
+  if (probe === null) { georgeUp = false; console.error('⚠ George unreachable — reply detection skipped; NOT queuing resends (can\'t confirm no-reply).'); }
+
+  for (const v of vendors) {
+    const bdays = businessDaysSince(v.lastSent);
+    let replied = replies.byVid[v.vid]?.replied || false;
+    let reply = replies.byVid[v.vid] || null;
+    if (georgeUp && !replied) {
+      const found = await findReply(v.emails, v.lastSent);
+      if (found) { replied = true; reply = { replied: true, ...found, foundAt: new Date().toISOString(), lastSent: v.lastSent }; replies.byVid[v.vid] = reply; }
+    }
+    const status = replied ? 'replied' : (bdays >= BIZ_THRESHOLD ? 'resend-due' : 'waiting');
+    console.log(`  ${status.padEnd(11)} ${v.name.padEnd(30)} [${v.vid}]  ${bdays} biz-day(s) since ${v.lastSent.slice(0,10)}${replied ? '  — reply: ' + (reply.snippet||'').slice(0,60) : ''}`);
+    // Queue a resend only when we KNOW there's no reply and the window has elapsed.
+    if (georgeUp && !replied && bdays >= BIZ_THRESHOLD) {
+      resend.queue.push({ slug: v.slug, vid: v.vid, name: v.name, to: v.emails.join(', '), lastSent: v.lastSent, bizDays: bdays });
+    }
+  }
+  mkdirSync(join(ROOT, 'data'), { recursive: true });
+  writeFileSync(join(ROOT, 'data', 'replies.json'), JSON.stringify(replies, null, 2));
+  writeFileSync(join(ROOT, 'data', 'resend-queue.json'), JSON.stringify(resend, null, 2));
+  console.log(`\nReplies recorded: ${Object.values(replies.byVid).filter((r) => r.replied).length} · Resend-due (≥${BIZ_THRESHOLD} biz days, no reply): ${resend.queue.length}`);
+  console.log(`→ data/replies.json  ·  data/resend-queue.json  (console shows the one-click resend flags)`);
+  if (replies.byVid && Object.values(replies.byVid).some((r) => r.replied))
+    console.log(`Tip: summarize each reply into FileMaker Priority Memo Notes →  node scripts/fmpro.mjs note --slug <slug> --sku <DW#> --text "Discontinued | Sending now | Already sent 7/30"`);
+};
+run().catch((e) => { console.error('poll-replies error:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index c3e905b..8da9faf 100644
--- a/server.js
+++ b/server.js
@@ -20,6 +20,8 @@ const fleet = () => readJSON(p('data', 'fleet.json'), { vendors: [] });
 const contacts = () => readJSON(p('data', 'contacts.json'), {});
 const sent = () => readJSON(p('data', 'sent.json'), { byEmail: {} });
 const fmposted = () => readJSON(p('data', 'fmpro-posted.json'), { byVid: {} }); // FileMaker write-back state (chip)
+const resendq = () => readJSON(p('data', 'resend-queue.json'), { queue: [] });   // 3-biz-day resend queue
+const replies = () => readJSON(p('data', 'replies.json'), { byVid: {} });         // vendor reply status
 
 // After a send, stamp "Date Email Sent to Vendor after 10 Days" on that vendor's exact
 // page SKUs in FileMaker (WALLPAPER2) and record it for the console chip. Fire-and-forget:
@@ -57,7 +59,17 @@ const server = http.createServer(async (req, res) => {
   const u = new URL(req.url, 'http://x');
 
   if (u.pathname === '/' ) return send(res, 200, PAGE, 'text/html; charset=utf-8');
-  if (u.pathname === '/api/state') return send(res, 200, { fleet: fleet(), contacts: contacts(), sent: sent(), fmposted: fmposted() });
+  if (u.pathname === '/api/state') return send(res, 200, { fleet: fleet(), contacts: contacts(), sent: sent(), fmposted: fmposted(), resend: resendq(), replies: replies() });
+
+  // Write a plain-language reply note to FileMaker Priority Memo Notes (one-click from the console).
+  if (u.pathname === '/api/note' && req.method === 'POST') {
+    const b = await body(req);
+    if (!b.slug || !b.sku || !b.text) return send(res, 400, { error: 'need slug, sku, text' });
+    const child = spawn(process.execPath, [p('scripts', 'fmpro.mjs'), 'note', '--slug', b.slug, '--sku', b.sku, '--text', b.text], { cwd: ROOT });
+    let out = ''; child.stdout.on('data', d => out += d); child.stderr.on('data', d => out += d);
+    child.on('close', () => send(res, 200, { ok: /committed":true/.test(out), detail: out.slice(0, 300) }));
+    return;
+  }
 
   // Re-harvest push: Claude searches info@ Sent (subject "Outstanding Memos") via MCP and
   // POSTs { byEmail } (or a raw George message list) here to refresh the sent snapshot.
@@ -233,7 +245,7 @@ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Fol
 <script>
 let S={fleet:{vendors:[]},contacts:{},sent:{byEmail:{}}};
 const sortKey=localStorage.getItem('sfsort')||'items';
-async function load(){const r=await fetch('/api/state');S=await r.json();if(!S.sent)S.sent={byEmail:{}};if(!S.fmposted)S.fmposted={byVid:{}};document.getElementById('sort').value=sortKey;render();
+async function load(){const r=await fetch('/api/state');S=await r.json();if(!S.sent)S.sent={byEmail:{}};if(!S.fmposted)S.fmposted={byVid:{}};if(!S.resend)S.resend={queue:[]};if(!S.replies)S.replies={byVid:{}};document.getElementById('sort').value=sortKey;render();
  const nSent=S.fleet.vendors.filter(v=>sentInfo(v).sent).length;
  const meta=document.getElementById('meta');
  meta.textContent=S.fleet.vendors.length+' vendors · '+S.fleet.totalItems+' items · window '+S.fleet.window+' · '+nSent+' emailed / '+(S.fleet.vendors.length-nSent)+' not yet';
@@ -277,6 +289,13 @@ function fmCell(v){const f=fmInfo(v);const s=sentInfo(v);
  if(f&&f.count)return '<span class="badge fmb" title="Posted to FileMaker: Date Email Sent to Vendor after 10 Days = '+esc(f.date||'')+' ('+esc(f.lastPostedAt||'')+')">✓ FMPro '+esc(f.date||'')+'</span>';
  if(s.sent)return '<span class=muted title="Emailed but sent-date not yet written back to FileMaker">— not posted</span>';
  return '<span class=muted>—</span>';}
+// Reply / 3-biz-day resend status (poll-replies.mjs feeds these)
+function resendDue(v){return ((S.resend&&S.resend.queue)||[]).some(q=>q.vid===v.vid);}
+function repliedInfo(v){return (S.replies&&S.replies.byVid&&S.replies.byVid[v.vid])||null;}
+function replyBadge(v){const rep=repliedInfo(v);
+ if(rep&&rep.replied)return ' <span class="badge" style="background:#e6f4ea;color:#137a34" title="'+esc(rep.snippet||'')+'">✉ replied</span>';
+ if(resendDue(v))return ' <span class="badge" style="background:#fdecc8;color:#8a5a00" title="3+ business days, no reply — use the resend button">⏰ resend due</span>';
+ return '';}
 function render(){const t=document.getElementById('rows');t.innerHTML='';for(const v of sorted()){const c=S.contacts[v.slug]||{};const r=ready(v);
  const noChase=c.disposition==='no-chase';
  const tr=document.createElement('tr');if(noChase)tr.style.opacity='.5';tr.innerHTML=
@@ -285,7 +304,7 @@ function render(){const t=document.getElementById('rows');t.innerHTML='';for(con
  '<td><button class=exp data-exp="'+v.slug+'">▶ '+v.count+'</button></td>'+
  '<td>'+(v.reReqCount?'<span class="badge rrb">'+v.reReqCount+'</span>':'<span class=muted>–</span>')+'</td>'+
  '<td>'+v.items[0].age+'d</td>'+
- '<td>'+(noChase?'<span class="badge dead" title="'+esc(c.disposition_reason||'')+'">NO-CHASE</span>':'<span class="badge '+(r?'ok':'need')+'">'+(r?'READY':'NEEDS')+'</span>')+'</td>'+
+ '<td>'+(noChase?'<span class="badge dead" title="'+esc(c.disposition_reason||'')+'">NO-CHASE</span>':'<span class="badge '+(r?'ok':'need')+'">'+(r?'READY':'NEEDS')+'</span>')+replyBadge(v)+'</td>'+
  '<td class="sentcell">'+sentCell(v)+'</td>'+
  '<td class="fmcell">'+fmCell(v)+'</td>'+
  '<td><input class="em" data-slug="'+v.slug+'" data-f="sample_email" value="'+esc(c.sample_email||'')+'" placeholder="sample email"></td>'+

← de3da21 FMPro write-back: stamp Date Email Sent to Vendor after 10 D  ·  back to Sample Followup Sweep  ·  Priority Memo Notes: write 21 clear vendor-reply notes to Fi a7de739 →