[object Object]

← back to George Gmail

Add labelIds support to POST /api/drafts and bulk-label endpoint

60d5c893dd1ed46c1971af8b808f0a742c593ceb · 2026-08-23 04:14:45 -0700 · Steve Abrams

- POST /api/drafts now accepts optional `labelIds` array — after draft creation,
  applies labels via users.messages.modify (the reliable path since drafts.create
  raw-message doesn't surface custom labelIds directly). Non-fatal if label apply fails.
- New POST /api/messages/bulk-label endpoint — searches messages by query `q` and
  applies/removes label IDs via batchModify in 1000-item chunks; paginated up to
  5000 messages; supports dryRun mode.
- New bulk-label-sample-followup-drafts.js script — uses the new endpoint to label
  all existing info@ drafts matching subject:"Sample Follow-Up" with the
  "sample-followup" label (TK-10744 info@ drafts cleanup).

Deploy to Kamatera (pm2 restart george) is gated — Steve-go required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 60d5c893dd1ed46c1971af8b808f0a742c593ceb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 23 04:14:45 2026 -0700

    Add labelIds support to POST /api/drafts and bulk-label endpoint
    
    - POST /api/drafts now accepts optional `labelIds` array — after draft creation,
      applies labels via users.messages.modify (the reliable path since drafts.create
      raw-message doesn't surface custom labelIds directly). Non-fatal if label apply fails.
    - New POST /api/messages/bulk-label endpoint — searches messages by query `q` and
      applies/removes label IDs via batchModify in 1000-item chunks; paginated up to
      5000 messages; supports dryRun mode.
    - New bulk-label-sample-followup-drafts.js script — uses the new endpoint to label
      all existing info@ drafts matching subject:"Sample Follow-Up" with the
      "sample-followup" label (TK-10744 info@ drafts cleanup).
    
    Deploy to Kamatera (pm2 restart george) is gated — Steve-go required.
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 bulk-label-sample-followup-drafts.js | 142 +++++++++++++++++++++++++++++++++++
 server.js                            |  83 +++++++++++++++++++-
 2 files changed, 222 insertions(+), 3 deletions(-)

diff --git a/bulk-label-sample-followup-drafts.js b/bulk-label-sample-followup-drafts.js
new file mode 100644
index 0000000..ec34262
--- /dev/null
+++ b/bulk-label-sample-followup-drafts.js
@@ -0,0 +1,142 @@
+#!/usr/bin/env node
+/*
+ * bulk-label-sample-followup-drafts.js  —  apply a Gmail label to all info@ drafts
+ * whose subject starts with "Sample Follow-Up".
+ *
+ * Context: TK-10744 — 3,685 un-labeled drafts in info@designerwallcoverings.com
+ * generated by the sample-followup agent with no per-source label. This script
+ * applies a label so agents can filter their own drafts cleanly.
+ *
+ * FLOW:
+ *   1. GET /api/labels?account=info  — discover existing labels
+ *   2. If LABEL_NAME doesn't exist, create it via the Gmail API (via George health call
+ *      or direct Gmail call — George doesn't expose a label-create endpoint yet, so we
+ *      call Gmail directly using George's stored OAuth token, OR you create the label
+ *      manually in Gmail and pass its ID as LABEL_ID env var).
+ *   3. POST /api/messages/bulk-label?account=info  — search + apply (dry run first).
+ *   4. DRY_RUN=0 to actually apply.
+ *
+ * USAGE:
+ *   node bulk-label-sample-followup-drafts.js           # dry run — shows count
+ *   DRY_RUN=0 node bulk-label-sample-followup-drafts.js # live — applies labels
+ *   LABEL_ID=Label_XXXXX DRY_RUN=0 node ...            # skip discovery, use known ID
+ *
+ * NOTE: George must be running locally (default port 9850).
+ * The script targets the 'info' account (info@designerwallcoverings.com).
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const BASE = process.env.GEORGE_BASE || 'http://127.0.0.1:9850';
+const ACC = process.env.ACCOUNT || 'info';
+const LABEL_NAME = process.env.LABEL_NAME || 'sample-followup';
+const LABEL_ID = process.env.LABEL_ID || ''; // skip discovery if known
+const DRY_RUN = process.env.DRY_RUN !== '0'; // default dry run
+const QUERY = `in:drafts subject:"Sample Follow-Up"`;
+
+function resolveAuth() {
+  let u = 'admin', p = '';
+  const envPath = path.join(process.env.HOME || '', 'Projects/Designer-Wallcoverings/DW-MCP/.env');
+  try {
+    const t = fs.readFileSync(envPath, 'utf8');
+    const m = t.match(/^GEORGE_BASIC_AUTH[^_]*=(.+)$/m);
+    if (m) {
+      const v = m[1].trim();
+      if (v.includes(':')) { const s = v.split(':'); u = s[0]; p = s.slice(1).join(':'); }
+    }
+    if (!p) {
+      const mp = t.match(/^GEORGE_BASIC_AUTH_PASS=(.+)$/m);
+      if (mp) p = mp[1].trim();
+    }
+  } catch (_) { /* fall back */ }
+  return { u, p };
+}
+
+const { u, p } = resolveAuth();
+const AUTH = 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
+const H = { Authorization: AUTH, 'Content-Type': 'application/json' };
+
+async function jget(url) {
+  const r = await fetch(url, { headers: H });
+  if (!r.ok) throw new Error(`GET ${url} → ${r.status} ${await r.text()}`);
+  return r.json();
+}
+
+async function jpost(url, body) {
+  const r = await fetch(url, { method: 'POST', headers: H, body: JSON.stringify(body) });
+  if (!r.ok) throw new Error(`POST ${url} → ${r.status} ${await r.text()}`);
+  return r.json();
+}
+
+async function main() {
+  console.log(`[bulk-label] George: ${BASE} | Account: ${ACC} | DRY_RUN: ${DRY_RUN}`);
+  console.log(`[bulk-label] Query: ${QUERY}`);
+  console.log(`[bulk-label] Label: ${LABEL_ID ? LABEL_ID : `"${LABEL_NAME}" (will discover)`}`);
+  console.log('');
+
+  // Step 1: Discover or confirm label ID
+  let labelId = LABEL_ID;
+  if (!labelId) {
+    console.log('[bulk-label] Fetching label list...');
+    const labels = await jget(`${BASE}/api/labels?account=${ACC}`);
+    const found = labels.find((l) => l.name.toLowerCase() === LABEL_NAME.toLowerCase());
+    if (found) {
+      labelId = found.id;
+      console.log(`[bulk-label] Found label "${found.name}" → ${labelId}`);
+    } else {
+      console.log(`[bulk-label] Label "${LABEL_NAME}" NOT FOUND in Gmail.`);
+      console.log('[bulk-label] Create the label in Gmail first, then re-run with LABEL_ID=<id>.');
+      console.log('[bulk-label] Available labels:');
+      labels.filter((l) => !l.id.startsWith('CATEGORY_')).forEach((l) => console.log(`  ${l.id}  ${l.name}`));
+      process.exit(1);
+    }
+  }
+
+  // Step 2: Dry run to count matching drafts
+  console.log('\n[bulk-label] Running dry-run search...');
+  const dryResult = await jpost(`${BASE}/api/messages/bulk-label?account=${ACC}`, {
+    q: QUERY,
+    addLabelIds: [labelId],
+    dryRun: true,
+  });
+  console.log(`[bulk-label] Matching drafts: ${dryResult.total}`);
+  if (dryResult.total > 0) {
+    console.log(`[bulk-label] Sample IDs: ${dryResult.ids.slice(0, 5).join(', ')}...`);
+  }
+
+  if (DRY_RUN) {
+    console.log('\n[bulk-label] DRY RUN — no labels applied. Set DRY_RUN=0 to apply.');
+    return;
+  }
+
+  if (dryResult.total === 0) {
+    console.log('[bulk-label] No matching drafts found — nothing to label.');
+    return;
+  }
+
+  // Step 3: Live label application
+  console.log(`\n[bulk-label] LIVE — applying label "${labelId}" to ${dryResult.total} drafts...`);
+  const result = await jpost(`${BASE}/api/messages/bulk-label?account=${ACC}`, {
+    q: QUERY,
+    addLabelIds: [labelId],
+    dryRun: false,
+  });
+  console.log(`[bulk-label] Done: labeled=${result.labeled} failed=${result.failed} total=${result.total}`);
+
+  // Write result heartbeat
+  const hbPath = path.join(__dirname, 'data', 'bulk-label-sample-followup-latest.json');
+  fs.mkdirSync(path.dirname(hbPath), { recursive: true });
+  fs.writeFileSync(hbPath, JSON.stringify({
+    ts: new Date().toISOString(),
+    account: ACC,
+    query: QUERY,
+    labelId,
+    labeled: result.labeled,
+    failed: result.failed,
+    total: result.total,
+  }, null, 2));
+  console.log(`[bulk-label] Heartbeat written: ${hbPath}`);
+}
+
+main().catch((e) => { console.error('[bulk-label] FATAL:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 4b89dc7..e8ecb97 100644
--- a/server.js
+++ b/server.js
@@ -1410,9 +1410,12 @@ app.post('/api/send-with-attachment', async (req, res) => {
 });
 
 // ─── API: Create Draft ───
+// Supports optional `labelIds` array — applied via messages.modify after draft creation
+// since the Gmail drafts.create raw-message path doesn't surface labelIds directly.
+// Example: POST /api/drafts?account=info  body: { to, subject, body, labelIds: ["Label_12345"] }
 app.post('/api/drafts', async (req, res) => {
   try {
-    const { to, subject, body, cc, bcc } = req.body;
+    const { to, subject, body, cc, bcc, labelIds } = req.body;
     if (!subject || !body) return res.status(400).json({ error: 'subject and body required' });
 
     const { gmail: g, key: account } = resolveAccount(req);
@@ -1422,8 +1425,27 @@ app.post('/api/drafts', async (req, res) => {
     const taggedBody = withSourceFooter(body, source);
     const encoded = buildRawMessage({ to, cc, bcc, subject, body: taggedBody });
     const result = await g.users.drafts.create({ userId: 'me', requestBody: { message: { raw: encoded } } });
-    audit(account, 'draft', { to, subject, source, draftId: result.data.id });
-    res.json({ success: true, draftId: result.data.id, messageId: result.data.message?.id, account, source });
+    const draftId = result.data.id;
+    const messageId = result.data.message?.id;
+
+    // Apply labels if requested (messages.modify is the reliable path for custom labels)
+    let appliedLabels = [];
+    if (Array.isArray(labelIds) && labelIds.length > 0 && messageId) {
+      try {
+        await g.users.messages.modify({
+          userId: 'me',
+          id: messageId,
+          requestBody: { addLabelIds: labelIds },
+        });
+        appliedLabels = labelIds;
+      } catch (labelErr) {
+        // Non-fatal: draft was created, just label application failed
+        console.warn(`[george] draft ${draftId} created but label apply failed: ${labelErr.message}`);
+      }
+    }
+
+    audit(account, 'draft', { to, subject, source, draftId, labelIds: appliedLabels });
+    res.json({ success: true, draftId, messageId, account, source, appliedLabels });
   } catch (e) {
     res.status(500).json({ error: e.message });
   }
@@ -1441,6 +1463,61 @@ app.get('/api/drafts', async (req, res) => {
   }
 });
 
+// ─── API: Bulk Label Messages/Drafts ───
+// POST /api/messages/bulk-label?account=info
+// body: { q: "in:drafts subject:\"Sample Follow-Up\"", addLabelIds: ["Label_ID"], removeLabelIds: [] }
+// Searches matching messages and applies/removes label IDs via batchModify.
+// Safe: read then batch-modify; paginated up to maxPages (default 10 x 500 = 5000 msgs).
+// Returns { labeled: N, failed: N, total: N, ids: [...first50] }
+app.post('/api/messages/bulk-label', async (req, res) => {
+  try {
+    const { q, addLabelIds, removeLabelIds, dryRun } = req.body;
+    if (!q) return res.status(400).json({ error: 'q (search query) required' });
+    if (!addLabelIds?.length && !removeLabelIds?.length) {
+      return res.status(400).json({ error: 'addLabelIds or removeLabelIds required' });
+    }
+    const { gmail: g, key: account } = resolveAccount(req);
+    if (!g) return res.status(400).json({ error: `unknown account: ${account}` });
+
+    // Paginate through search results
+    const PAGE = 500;
+    const MAX_PAGES = parseInt(req.body.maxPages || '10', 10);
+    let allIds = [];
+    let pageToken;
+    for (let p = 0; p < MAX_PAGES; p++) {
+      const params = { userId: 'me', q, maxResults: PAGE };
+      if (pageToken) params.pageToken = pageToken;
+      const r = await g.users.messages.list(params);
+      const msgs = r.data.messages || [];
+      allIds.push(...msgs.map((m) => m.id));
+      pageToken = r.data.nextPageToken;
+      if (!pageToken || msgs.length < PAGE) break;
+    }
+
+    if (allIds.length === 0) return res.json({ labeled: 0, failed: 0, total: 0, ids: [], dryRun: true });
+    if (dryRun) return res.json({ labeled: 0, failed: 0, total: allIds.length, ids: allIds.slice(0, 50), dryRun: true });
+
+    // batchModify in chunks of 1000 (Gmail API limit)
+    let labeled = 0, failed = 0;
+    const CHUNK = 1000;
+    const modify = { addLabelIds: addLabelIds || [], removeLabelIds: removeLabelIds || [] };
+    for (let i = 0; i < allIds.length; i += CHUNK) {
+      try {
+        await g.users.messages.batchModify({ userId: 'me', requestBody: { ids: allIds.slice(i, i + CHUNK), ...modify } });
+        labeled += Math.min(CHUNK, allIds.length - i);
+      } catch (e) {
+        failed += Math.min(CHUNK, allIds.length - i);
+        console.warn(`[george] bulk-label chunk ${i} failed: ${e.message}`);
+      }
+    }
+
+    audit(account, 'bulk-label', { q, addLabelIds, removeLabelIds, total: allIds.length, labeled, failed });
+    res.json({ labeled, failed, total: allIds.length, ids: allIds.slice(0, 50), dryRun: false });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ─── API: Delete a Draft ───
 // DELETE /api/drafts/:id?account=info  — permanently removes the draft (not trash).
 app.delete('/api/drafts/:id', async (req, res) => {

← ba0786d auto-data-snapshot: 2026-08-22T14:31:07 (1 data files) — dat  ·  back to George Gmail  ·  auto-data-snapshot: 2026-08-23T04:19:03 (1 data files) — dat 8d0b000 →