← 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
A bulk-label-sample-followup-drafts.jsM server.js
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 →