From c3e5f669eda6e3e3a036cb7e0850af9cca744915 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Sat, 28 Mar 2026 02:50:42 -0500 Subject: [PATCH] Fix Stefano PDF email delivery path --- docs/google-workspace.md | 7 ++ docs/property-assessor.md | 4 +- integrations/google-workspace/gw.js | 115 ++++++++++++++++++--- integrations/google-workspace/gw.test.js | 34 ++++++ integrations/google-workspace/package.json | 2 +- skills/property-assessor/SKILL.md | 4 +- 6 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 integrations/google-workspace/gw.test.js diff --git a/docs/google-workspace.md b/docs/google-workspace.md index e75f9b1..b9ce37b 100644 --- a/docs/google-workspace.md +++ b/docs/google-workspace.md @@ -41,7 +41,14 @@ Optional env: ```bash node integrations/google-workspace/gw.js whoami node integrations/google-workspace/gw.js send --to "user@example.com" --subject "Hello" --body "Hi there" +node integrations/google-workspace/gw.js send --to "user@example.com" --subject "Report" --body "Attached is the PDF." --attach /tmp/report.pdf node integrations/google-workspace/gw.js search-mail --query "from:someone@example.com newer_than:7d" --max 10 node integrations/google-workspace/gw.js search-calendar --timeMin 2026-03-17T00:00:00-05:00 --timeMax 2026-03-18T00:00:00-05:00 --max 20 node integrations/google-workspace/gw.js create-event --summary "Meeting" --start 2026-03-20T09:00:00-05:00 --end 2026-03-20T10:00:00-05:00 ``` + +## Attachments + +- `send` now supports one or more `--attach /path/to/file` arguments. +- Attachment content type is inferred from the filename extension; PDF attachments are sent as `application/pdf`. +- The default impersonation remains `stefano@fiorinis.com`, so this is the correct helper for actions explicitly performed on Stefano's behalf. diff --git a/docs/property-assessor.md b/docs/property-assessor.md index 483eb7f..b8b68bd 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -77,7 +77,9 @@ Operational rule: - For CAD/public-record lookup, prefer official assessor/CAD pages via `web_fetch` first and `web-automation` second if the site needs rendered interaction. - In those messaging runs, reserve subprocess use for a single final `render-report` attempt after the verdict and fair-value range are complete. - In those messaging runs, do not start Gmail/email-send skill discovery or delivery tooling until the report content is complete and the PDF is ready to render or already rendered. -- If delivery is being performed on Stefano's behalf, do not route it through Luke-only wrappers such as `gog-luke`; use the Stefano/user delivery path configured for this workspace. +- If delivery is being performed on Stefano's behalf, use `node ~/.openclaw/workspace/integrations/google-workspace/gw.js send --to "" --subject "" --body "" --attach ""`. +- Do not route property-assessor delivery through generic `gog`, `gog auth list`, or Luke-only wrappers such as `gog-luke`. +- If the agent needs to confirm the active Stefano identity before sending, use `node ~/.openclaw/workspace/integrations/google-workspace/gw.js whoami`. - Treat a silent helper as a failed helper in messaging runs. If a helper produces no useful output within a short bound, abandon it and continue with the chat-native path instead of repeatedly polling it. - If the original request already authorized sending the finished PDF to a stated email address, do not pause for a redundant send-confirmation prompt after rendering. - If final PDF render/send fails, return the completed decision-grade report in chat and report delivery failure separately rather than restarting the whole assessment. diff --git a/integrations/google-workspace/gw.js b/integrations/google-workspace/gw.js index ed9d235..51ff8c3 100755 --- a/integrations/google-workspace/gw.js +++ b/integrations/google-workspace/gw.js @@ -11,7 +11,6 @@ const fs = require('fs'); const path = require('path'); -const { google } = require('googleapis'); const DEFAULT_SUBJECT = process.env.GW_IMPERSONATE || 'stefano@fiorinis.com'; const DEFAULT_KEY_CANDIDATES = [ @@ -45,7 +44,11 @@ function parseArgs(argv) { if (!next || next.startsWith('--')) { out[key] = true; } else { - out[key] = next; + if (Object.hasOwn(out, key)) { + out[key] = Array.isArray(out[key]) ? out[key].concat(next) : [out[key], next]; + } else { + out[key] = next; + } i++; } } else { @@ -63,7 +66,7 @@ Env (optional): Commands: whoami - send --to --subject --body [--html] + send --to --subject --body [--html] [--attach ] search-mail --query [--max 10] search-calendar --query [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary] create-event --summary --start --end [--timeZone America/Chicago] [--description ] [--location ] [--calendar primary] @@ -77,17 +80,82 @@ function assertRequired(opts, required) { } } -function makeRawEmail({ from, to, subject, body, isHtml = false }) { +function toArray(value) { + if (value == null || value === false) return []; + return Array.isArray(value) ? value : [value]; +} + +function getAttachmentContentType(filename) { + const ext = path.extname(filename).toLowerCase(); + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.txt') return 'text/plain; charset="UTF-8"'; + if (ext === '.html' || ext === '.htm') return 'text/html; charset="UTF-8"'; + if (ext === '.json') return 'application/json'; + return 'application/octet-stream'; +} + +function wrapBase64(base64) { + return base64.match(/.{1,76}/g)?.join('\r\n') || ''; +} + +function loadAttachments(attachArg) { + return toArray(attachArg).map((filePath) => { + const absolutePath = path.resolve(filePath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Attachment file not found: ${absolutePath}`); + } + + return { + filename: path.basename(absolutePath), + contentType: getAttachmentContentType(absolutePath), + data: fs.readFileSync(absolutePath).toString('base64'), + }; + }); +} + +function makeRawEmail({ from, to, subject, body, isHtml = false, attachments = [] }) { const contentType = isHtml ? 'text/html; charset="UTF-8"' : 'text/plain; charset="UTF-8"'; - const msg = [ - `From: ${from}`, - `To: ${to}`, - `Subject: ${subject}`, - 'MIME-Version: 1.0', - `Content-Type: ${contentType}`, - '', - body, - ].join('\r\n'); + const normalizedAttachments = attachments.filter(Boolean); + + let msg; + if (normalizedAttachments.length === 0) { + msg = [ + `From: ${from}`, + `To: ${to}`, + `Subject: ${subject}`, + 'MIME-Version: 1.0', + `Content-Type: ${contentType}`, + '', + body, + ].join('\r\n'); + } else { + const boundary = `gw-boundary-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + msg = [ + `From: ${from}`, + `To: ${to}`, + `Subject: ${subject}`, + 'MIME-Version: 1.0', + `Content-Type: multipart/mixed; boundary="${boundary}"`, + '', + `--${boundary}`, + `Content-Type: ${contentType}`, + 'Content-Transfer-Encoding: 7bit', + '', + body, + '', + ...normalizedAttachments.flatMap((attachment) => [ + `--${boundary}`, + `Content-Type: ${attachment.contentType}; name="${attachment.filename}"`, + 'Content-Transfer-Encoding: base64', + `Content-Disposition: attachment; filename="${attachment.filename}"`, + '', + wrapBase64(attachment.data), + '', + ]), + `--${boundary}--`, + '', + ].join('\r\n'); + } return Buffer.from(msg) .toString('base64') @@ -97,6 +165,7 @@ function makeRawEmail({ from, to, subject, body, isHtml = false }) { } async function getClients() { + const { google } = require('googleapis'); const keyPath = resolveKeyPath(); if (!keyPath) { throw new Error('Service account key not found. Set GW_KEY_PATH or place the file in ~/.openclaw/workspace/.clawdbot/credentials/google-workspace/service-account.json'); @@ -132,6 +201,7 @@ async function cmdSend(clients, opts) { subject: opts.subject, body: opts.body, isHtml: !!opts.html, + attachments: loadAttachments(opts.attach), }); const res = await clients.gmail.users.messages.send({ @@ -222,7 +292,7 @@ async function cmdCreateEvent(clients, opts) { console.log(JSON.stringify({ ok: true, id: res.data.id, htmlLink: res.data.htmlLink }, null, 2)); } -(async function main() { +async function main() { try { const args = parseArgs(process.argv.slice(2)); const cmd = args._[0]; @@ -245,4 +315,19 @@ async function cmdCreateEvent(clients, opts) { console.error(`ERROR: ${err.message}`); process.exit(1); } -})(); +} + +if (require.main === module) { + main(); +} + +module.exports = { + getAttachmentContentType, + loadAttachments, + main, + makeRawEmail, + parseArgs, + resolveKeyPath, + toArray, + wrapBase64, +}; diff --git a/integrations/google-workspace/gw.test.js b/integrations/google-workspace/gw.test.js new file mode 100644 index 0000000..4324b7c --- /dev/null +++ b/integrations/google-workspace/gw.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const { makeRawEmail } = require('./gw.js'); + +test('help documents attachment support for send', () => { + const gwPath = path.join(__dirname, 'gw.js'); + const result = spawnSync(process.execPath, [gwPath, 'help'], { encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /send --to --subject --body \[--html\] \[--attach \]/); +}); + +test('makeRawEmail builds multipart messages when attachments are present', () => { + const raw = makeRawEmail({ + from: 'stefano@fiorinis.com', + to: 'stefano@fiorinis.com', + subject: 'Attachment test', + body: 'Attached PDF.', + attachments: [ + { + filename: 'report.pdf', + contentType: 'application/pdf', + data: Buffer.from('%PDF-1.4\n%test\n').toString('base64'), + }, + ], + }); + + const decoded = Buffer.from(raw, 'base64').toString('utf8'); + assert.match(decoded, /Content-Type: multipart\/mixed; boundary=/); + assert.match(decoded, /Content-Disposition: attachment; filename="report\.pdf"/); + assert.match(decoded, /Content-Type: application\/pdf; name="report\.pdf"/); +}); diff --git a/integrations/google-workspace/package.json b/integrations/google-workspace/package.json index 9f485cc..4916faa 100644 --- a/integrations/google-workspace/package.json +++ b/integrations/google-workspace/package.json @@ -4,7 +4,7 @@ "description": "", "main": "gw.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test" }, "keywords": [], "author": "", diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index d24290c..698c8be 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -63,7 +63,9 @@ Rules: - In those messaging runs, do **not** make `scripts/property-assessor assess`, `scripts/property-assessor locate-public-records`, `node zillow-discover.js`, `node har-discover.js`, `node zillow-photos.js`, `node har-photos.js`, `curl`, or `wget` the default core-analysis path. - From messaging runs, the only subprocess-style step you should attempt by default is the final `scripts/property-assessor render-report` call after the verdict, fair-value range, and report body are complete. - Do **not** inspect Gmail/email-send skills, mail tooling, or delivery integrations until the assessment is complete and the PDF is either already rendered or ready to render immediately. -- If delivery is requested on Stefano's behalf, do **not** use Luke-only Google wrappers such as `gog-luke`. Use the Stefano/user delivery path configured for this workspace. +- If delivery is requested on Stefano's behalf, use `node ~/.openclaw/workspace/integrations/google-workspace/gw.js send --to "" --subject "" --body "" --attach ""`. +- For Stefano-behalf delivery from this workflow, do **not** use generic `gog`, `gog auth list`, or Luke-only wrappers such as `gog-luke`. +- If you need to confirm the active Stefano identity before sending, use `node ~/.openclaw/workspace/integrations/google-workspace/gw.js whoami`. - A silent helper is a failed helper in messaging runs. If a background helper produces no useful stdout/stderr and no result within a short bound, stop polling it, treat that path as failed, and continue on the chat-native assessment path instead of narrating that it is still chewing. - Do **not** leave the user parked behind background helper polling. If a helper has not produced a result quickly, give a concise status update and continue the assessment with the next available non-helper path. - If the user already instructed you to email/send the finished PDF to a specific target, do **not** ask for a second send confirmation after rendering. Render, send, and report the result.