Fix Stefano PDF email delivery path
This commit is contained in:
@@ -41,7 +41,14 @@ Optional env:
|
|||||||
```bash
|
```bash
|
||||||
node integrations/google-workspace/gw.js whoami
|
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 "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-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 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
|
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.
|
||||||
|
|||||||
@@ -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.
|
- 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, 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.
|
- 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 "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`.
|
||||||
|
- 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.
|
- 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 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.
|
- 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.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { google } = require('googleapis');
|
|
||||||
|
|
||||||
const DEFAULT_SUBJECT = process.env.GW_IMPERSONATE || 'stefano@fiorinis.com';
|
const DEFAULT_SUBJECT = process.env.GW_IMPERSONATE || 'stefano@fiorinis.com';
|
||||||
const DEFAULT_KEY_CANDIDATES = [
|
const DEFAULT_KEY_CANDIDATES = [
|
||||||
@@ -45,7 +44,11 @@ function parseArgs(argv) {
|
|||||||
if (!next || next.startsWith('--')) {
|
if (!next || next.startsWith('--')) {
|
||||||
out[key] = true;
|
out[key] = true;
|
||||||
} else {
|
} 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++;
|
i++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -63,7 +66,7 @@ Env (optional):
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
whoami
|
whoami
|
||||||
send --to <email> --subject <text> --body <text> [--html]
|
send --to <email> --subject <text> --body <text> [--html] [--attach <file>]
|
||||||
search-mail --query <gmail query> [--max 10]
|
search-mail --query <gmail query> [--max 10]
|
||||||
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
|
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
|
||||||
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--calendar primary]
|
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--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 contentType = isHtml ? 'text/html; charset="UTF-8"' : 'text/plain; charset="UTF-8"';
|
||||||
const msg = [
|
const normalizedAttachments = attachments.filter(Boolean);
|
||||||
`From: ${from}`,
|
|
||||||
`To: ${to}`,
|
let msg;
|
||||||
`Subject: ${subject}`,
|
if (normalizedAttachments.length === 0) {
|
||||||
'MIME-Version: 1.0',
|
msg = [
|
||||||
`Content-Type: ${contentType}`,
|
`From: ${from}`,
|
||||||
'',
|
`To: ${to}`,
|
||||||
body,
|
`Subject: ${subject}`,
|
||||||
].join('\r\n');
|
'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)
|
return Buffer.from(msg)
|
||||||
.toString('base64')
|
.toString('base64')
|
||||||
@@ -97,6 +165,7 @@ function makeRawEmail({ from, to, subject, body, isHtml = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getClients() {
|
async function getClients() {
|
||||||
|
const { google } = require('googleapis');
|
||||||
const keyPath = resolveKeyPath();
|
const keyPath = resolveKeyPath();
|
||||||
if (!keyPath) {
|
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');
|
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,
|
subject: opts.subject,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
isHtml: !!opts.html,
|
isHtml: !!opts.html,
|
||||||
|
attachments: loadAttachments(opts.attach),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await clients.gmail.users.messages.send({
|
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));
|
console.log(JSON.stringify({ ok: true, id: res.data.id, htmlLink: res.data.htmlLink }, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
(async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
const cmd = args._[0];
|
const cmd = args._[0];
|
||||||
@@ -245,4 +315,19 @@ async function cmdCreateEvent(clients, opts) {
|
|||||||
console.error(`ERROR: ${err.message}`);
|
console.error(`ERROR: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAttachmentContentType,
|
||||||
|
loadAttachments,
|
||||||
|
main,
|
||||||
|
makeRawEmail,
|
||||||
|
parseArgs,
|
||||||
|
resolveKeyPath,
|
||||||
|
toArray,
|
||||||
|
wrapBase64,
|
||||||
|
};
|
||||||
|
|||||||
34
integrations/google-workspace/gw.test.js
Normal file
34
integrations/google-workspace/gw.test.js
Normal file
@@ -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 <email> --subject <text> --body <text> \[--html\] \[--attach <file>\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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"/);
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "gw.js",
|
"main": "gw.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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 "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`.
|
||||||
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user