Skill: joustonhuang/cdp-gmail-delivery (version 1.0.8)

--- name: cdp-gmail-delivery description: "Send Gmail messages from an operator-controlled Chrome debug session using Gmail CDP automation. Use when the user asks to send email from the local machine, with or without an attachment. Default assumption: Chrome CDP is already installed and available. If the CDP endpoint is unavailable, ask the human operator to help restart the debug Chrome session, then send, and verify in Sent by unique subject." metadata: {"openclaw":{"requires":{"bins":["node","npm"]}}} --- # Gmail CDP Delivery Use this workflow when a user asks to send email by Gmail from the local machine, with or without an attachment. Current limitation: this skill supports plain-text Gmail body content plus attachments. It does not reliably support sending raw HTML as inline Gmail body content. ## Required Inputs - Recipient email - Optional file path or paths - Optional body text ## Environment Assumptions - Chrome with remote debugging support is already installed and expected to be available - Chrome debug launcher exists in your repo/workspace when restart help is needed: - `scripts/restart_debug_chrome.sh` - Chrome DevTools endpoint is `http://127.0.0.1:9222` - Node and npm are available - This skill's runtime dependency is installed once with the bundled installer into `skills/cdp-gmail-delivery/.runtime/pupp-mail` ## Preflight Checklist - If attaching a file, confirm target file exists and is readable - If attaching files for a personal Gmail account, keep total size at or under 25 MB - Reject obviously blocked Gmail attachment classes before send: - executable/script-heavy file types like `.exe`, `.dll`, `.js`, `.jar`, `.bat`, `.cmd`, `.msi`, `.iso`, and similar blocked extensions - blocked files inside archives, including compressed forms or nested archives - password-protected archives - documents with malicious macros - Confirm recipient email is explicit - Confirm CDP endpoint responds (`http://127.0.0.1:9222/json/version`) - Confirm user login state in visible debug Chrome session If a requested attachment appears blocked or too large for Gmail, stop and ask the human operator for another delivery path. Do not keep retrying the same blocked upload. If the user wants an HTML newsletter or raw HTML email body, treat inline HTML body delivery as unsupported for this skill under the current Gmail compose + CDP model. Use plain-text summary/body plus an `.html` attachment instead, or use a future Gmail API / MIME-based workflow. For detectable cases, `scripts/send_via_cdp.js` should fail before opening Gmail and print a limitation result instead of pretending the upload might work. ## Workflow 1. Assume Chrome CDP is already available and try the send flow first. 2. If the CDP endpoint is unavailable, run `scripts/restart_debug_chrome.sh` from repo/workspace root. 3. If CDP is still unavailable after restart, ask the human operator to help restore the visible debug Chrome session. 4. If Gmail is not already authenticated in that visible Chrome window, ask the operator to sign in manually. 5. Send mail over CDP with `scripts/send_via_cdp.js`. 6. Verify send in Sent folder by a unique subject. ## Non-negotiable Validation Before clicking Send, validate all of the following in the live compose draft: - To field contains intended recipient - Subject is non-empty and unique - If attaching files, the compose draft shows all requested filenames before send If any check fails, stop and repair draft fields before send. ## Gmail Attachment Constraints - Personal Gmail sending limit: 25 MB total attachment size - Google Workspace accounts may use admin-defined limits instead - Gmail blocks many risky attachment types, including executable and script-like formats such as: - `.ade`, `.adp`, `.apk`, `.appx`, `.appxbundle`, `.bat`, `.cab`, `.chm`, `.cmd`, `.com`, `.cpl`, `.diagcab`, `.diagcfg`, `.diagpkg`, `.dll`, `.dmg`, `.ex`, `.ex_`, `.exe`, `.hta`, `.img`, `.ins`, `.iso`, `.isp`, `.jar`, `.jnlp`, `.js`, `.jse`, `.lib`, `.lnk`, `.mde`, `.mjs`, `.msc`, `.msi`, `.msix`, `.msixbundle`, `.msp`, `.mst`, `.nsh`, `.pif`, `.ps1`, `.scr`, `.sct`, `.shb`, `.sys`, `.vb`, `.vbe`, `.vbs`, `.vhd`, `.vxd`, `.wsc`, `.wsf`, `.wsh`, `.xll` - Gmail can also block: - compressed forms of blocked files - blocked files found inside archives like `.zip` or `.tgz` - password-protected archives - documents with malicious macros Use these constraints as preflight filters. If the file is likely to be blocked, do not claim it can be mailed successfully. ## HTML Email Limitation - Do not promise raw HTML body delivery through Gmail compose DOM automation - Gmail compose currently behaves like a plain-text/body-editor automation target for this skill, not a reliable raw HTML injection target - Treat HTML email as a limitation of this skill, not a temporary success path - Current supported workaround: - put a plain-text summary or intro in the email body - attach the full `.html` file - Future upgrade path for true inline HTML email: - Gmail API - raw MIME message assembly ## Install (one-time) From the workspace root, run: ```bash bash skills/cdp-gmail-delivery/scripts/install_runtime.sh ``` What it does: - Creates `skills/cdp-gmail-delivery/.runtime/pupp-mail` - Initializes a minimal npm runtime there - Installs pinned `puppeteer-core@24` This keeps the runtime local to the skill instead of using an ad hoc `/tmp` path. ## Send Command ```bash node skills/cdp-gmail-delivery/scripts/send_via_cdp.js \ --to "[REDACTED_SECRET]" \ --body "Optional message" ``` With attachment: ```bash node skills/cdp-gmail-delivery/scripts/send_via_cdp.js \ --to "[REDACTED_SECRET]" \ --file "/absolute/path/to/file.txt" \ --body "Optional message" ``` With multiple attachments: ```bash node skills/cdp-gmail-delivery/scripts/send_via_cdp.js \ --to "[REDACTED_SECRET]" \ --file "/absolute/path/to/file-a.txt" \ --file "/absolute/path/to/file-b.txt" \ --body "Optional message" ``` ## Success Output The script prints: - `EMAIL_SENT_OK` - `SUBJECT=<unique-subject>` - `TO=<recipient>` - `FILE_NAME=<basename-only>` for each attached file Report success only after these outputs and Sent verification succeed. Do not expose absolute local filesystem paths in success messages. When local preflight detects a Gmail attachment limitation, the script should print: - `LIMITATION_GMAIL_ATTACHMENT` - `REASON=<limitation-type>` - `DETAILS=<detected-context>` when available - `ACTION=...` telling the human operator this is a limitation of Gmail attachment sending for this skill ## When to read extra references - If send flow fails or behaves unexpectedly: read `references/troubleshooting.md` - If you need incident evidence/context: read `references/receipts.md` ## References - Runtime model: - This workflow uses direct Chrome CDP automation (Puppeteer), not agent-browser. - Browser runtime/bootstrap foundation: - `https://github.com/joustonhuang/chrome_for_openclaw` - Script references: `chrome_for_openclaw.sh`, `scripts/restart_debug_chrome.sh` - Historical script context: - `scripts/xrdp_chrome_debug_setup.sh` - Git repo context: `https://github.com/joustonhuang/unifai` - Incident receipts: - `references/receipts.md` - Troubleshooting: - `references/troubleshooting.md`

path: scripts/install_runtime.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
RUNTIME_DIR="${CDP_GMAIL_DELIVERY_RUNTIME_DIR:-$SKILL_DIR/.runtime/pupp-mail}"

mkdir -p "$RUNTIME_DIR"
cd "$RUNTIME_DIR"

if [ ! -f package.json ]; then
  npm init -y >/dev/null 2>&1
fi

npm install --no-fund --no-audit puppeteer-core@24

echo "CDP_GMAIL_DELIVERY_RUNTIME_OK"
echo "RUNTIME_DIR=$RUNTIME_DIR"


path: scripts/send_via_cdp.js
content: #!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const PERSONAL_GMAIL_MAX_BYTES = 25 * 1024 * 1024;
const BLOCKED_EXTENSIONS = new Set([
  '.ade', '.adp', '.apk', '.appx', '.appxbundle', '.bat', '.cab', '.chm', '.cmd', '.com', '.cpl',
  '.diagcab', '.diagcfg', '.diagpkg', '.dll', '.dmg', '.ex', '.ex_', '.exe', '.hta', '.img',
  '.ins', '.iso', '.isp', '.jar', '.jnlp', '.js', '.jse', '.lib', '.lnk', '.mde', '.mjs', '.msc',
  '.msi', '.msix', '.msixbundle', '.msp', '.mst', '.nsh', '.pif', '.ps1', '.scr', '.sct', '.shb',
  '.sys', '.vb', '.vbe', '.vbs', '.vhd', '.vxd', '.wsc', '.wsf', '.wsh', '.xll',
]);
const MACRO_ENABLED_EXTENSIONS = new Set(['.docm', '.dotm', '.xlsm', '.xltm', '.xlam', '.pptm', '.potm', '.ppsm']);

function emitGmailLimitation(reason, details) {
  console.log('LIMITATION_GMAIL_ATTACHMENT');
  console.log(`REASON=${reason}`);
  if (details) console.log(`DETAILS=${details}`);
  console.log('ACTION=Tell the human operator this is a limitation of Gmail attachment sending for cdp-gmail-delivery and ask for another delivery path.');
  process.exit(2);
}

function fileExtension(name) {
  return path.extname(name || '').toLowerCase();
}

function inspectZipEntries(buffer) {
  const entries = [];
  let encrypted = false;
  let offset = 0;

  while (offset + 46 <= buffer.length) {
    if (buffer.readUInt32LE(offset) !== 0x02014b50) {
      offset += 1;
      continue;
    }

    const generalPurposeFlag = buffer.readUInt16LE(offset + 8);
    const fileNameLength = buffer.readUInt16LE(offset + 28);
    const extraLength = buffer.readUInt16LE(offset + 30);
    const commentLength = buffer.readUInt16LE(offset + 32);
    const nameStart = offset + 46;
    const nameEnd = nameStart + fileNameLength;
    if (nameEnd > buffer.length) break;

    const name = buffer.slice(nameStart, nameEnd).toString('utf8');
    entries.push(name);
    if (generalPurposeFlag & 0x1) encrypted = true;

    offset = nameEnd + extraLength + commentLength;
  }

  return { entries, encrypted };
}

function preflightAttachments(files) {
  if (!files.length) return;

  const totalBytes = files.reduce((sum, file) => sum + fs.statSync(file).size, 0);
  if (totalBytes > PERSONAL_GMAIL_MAX_BYTES) {
    emitGmailLimitation('ATTACHMENTS_TOO_LARGE', `total_bytes=${totalBytes}; personal_gmail_limit=${PERSONAL_GMAIL_MAX_BYTES}`);
  }

  for (const file of files) {
    const base = path.basename(file);
    const ext = fileExtension(base);

    if (BLOCKED_EXTENSIONS.has(ext)) {
      emitGmailLimitation('BLOCKED_FILE_TYPE', base);
    }

    if (MACRO_ENABLED_EXTENSIONS.has(ext)) {
      emitGmailLimitation('MACRO_ENABLED_DOCUMENT', base);
    }

    if (ext === '.zip') {
      try {
        const { entries, encrypted } = inspectZipEntries(fs.readFileSync(file));
        if (encrypted) {
          emitGmailLimitation('PASSWORD_PROTECTED_ARCHIVE', base);
        }

        for (const entry of entries) {
          const entryBase = path.basename(entry);
          const entryExt = fileExtension(entryBase);
          if (BLOCKED_EXTENSIONS.has(entryExt)) {
            emitGmailLimitation('ARCHIVE_CONTAINS_BLOCKED_FILE_TYPE', `${base}:${entry}`);
          }
          if (MACRO_ENABLED_EXTENSIONS.has(entryExt) || /(^|\/)vbaProject\.bin$/i.test(entry)) {
            emitGmailLimitation('ARCHIVE_CONTAINS_MACRO_ENABLED_DOCUMENT', `${base}:${entry}`);
          }
        }
      } catch {
        // Best-effort inspection only. If zip parsing fails, leave enforcement to Gmail.
      }
    }
  }
}

function loadPuppeteer() {
  const candidates = [
    process.env.CDP_GMAIL_DELIVERY_PUPPETEER,
    path.join(__dirname, '..', '.runtime', 'pupp-mail', 'node_modules', 'puppeteer-core'),
    path.join(process.cwd(), 'node_modules', 'puppeteer-core'),
    'puppeteer-core',
  ].filter(Boolean);

  for (const candidate of candidates) {
    try {
      return require(candidate);
    } catch {}
  }

  throw new Error(
    'Unable to load puppeteer-core. Run: bash skills/cdp-gmail-delivery/scripts/install_runtime.sh'
  );
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function getArg(name, fallback = '') {
  const idx = process.argv.indexOf(`--${name}`);
  if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
  return fallback;
}

function getArgs(name) {
  const values = [];
  for (let i = 0; i < process.argv.length; i++) {
    if (process.argv[i] === `--${name}` && process.argv[i + 1]) values.push(process.argv[i + 1]);
  }
  return values;
}

(async () => {
  const to = getArg('to');
  const files = getArgs('file');
  const body = getArg('body', files.length ? 'Hi, attached are the requested files.' : 'Hi, this is the requested message.');

  if (!to) throw new Error('Missing --to');
  for (const file of files) {
    if (!fs.existsSync(file)) throw new Error(`File not found: ${file}`);
  }
  preflightAttachments(files);

  const puppeteer = loadPuppeteer();

  const bases = files.map((file) => path.basename(file));
  const textOnlyToken = crypto.randomBytes(4).toString('hex');
  const base = bases.length === 1 ? bases[0] : bases.length > 1 ? `gmail-files-${bases.length}` : `gmail-message-${textOnlyToken}`;
  const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 12);
  const subject = `${base} ${stamp}`;

  const browser = await puppeteer.connect({ browserURL: 'http://127.0.0.1:9222' });
  const page = (await browser.pages())[0] || (await browser.newPage());
  await page.bringToFront();
  await page.goto('https://mail.google.com/mail/u/0/#inbox', { waitUntil: 'domcontentloaded' });
  await page.waitForSelector('body');
  await sleep(1200);

  // close any existing draft first to avoid duplicate attachments/fields
  await page.evaluate(() => {
    const vis = (el) => {
      if (!el) return false;
      const r = el.getBoundingClientRect();
      const s = getComputedStyle(el);
      return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
    };
    const discard = [...document.querySelectorAll('div[role="button"][aria-label*="Discard draft"], div[role="button"][aria-label*="捨棄草稿"], div[role="button"][aria-label*="Discard"]')].find(vis);
    if (discard) discard.click();
  });

  await sleep(800);

  // always open a fresh compose window
  const composeClicked = await page.evaluate(() => {
    const vis = (el) => {
      if (!el) return false;
      const r = el.getBoundingClientRect();
      const s = getComputedStyle(el);
      return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
    };
    const el = [...document.querySelectorAll('div[gh="cm"]')].find(vis) || document.querySelector('div[gh="cm"]');
    if (!el) return false;
    el.click();
    return true;
  });
  if (!composeClicked) throw new Error('Compose button not found');
  await page.waitForSelector('input[aria-label="To recipients"], input[aria-label*="To"]', { timeout: 20000 });

  // fill fields
  const filled = await page.evaluate((to, subject, body) => {
    const vis = (el) => {
      if (!el) return false;
      const r = el.getBoundingClientRect();
      const s = getComputedStyle(el);
      return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
    };

    const dialogs = [...document.querySelectorAll('div[role="dialog"]')];
    const activeDialog = dialogs.find(vis) || dialogs[dialogs.length - 1] || document;

    const toCandidates = [...activeDialog.querySelectorAll('input[aria-label="To recipients"], input[aria-label*="To"]')];
    const subCandidates = [...activeDialog.querySelectorAll('input[name="subjectbox"]')];
    const bodyCandidates = [...activeDialog.querySelectorAll('div[aria-label="Message Body"], div[role="textbox"]')];

    const toEl = toCandidates.find(vis) || toCandidates[toCandidates.length - 1];
    const subEl = subCandidates.find(vis) || subCandidates[subCandidates.length - 1];
    const bodyEl = bodyCandidates.find(vis) || bodyCandidates[bodyCandidates.length - 1];

    if (!toEl || !subEl || !bodyEl) return { ok: false };

    toEl.focus();
    toEl.value = '';
    toEl.dispatchEvent(new Event('input', { bubbles: true }));
    toEl.value = to;
    toEl.dispatchEvent(new Event('input', { bubbles: true }));
    toEl.dispatchEvent(new Event('change', { bubbles: true }));

    subEl.focus();
    subEl.value = '';
    subEl.dispatchEvent(new Event('input', { bubbles: true }));
    subEl.value = subject;
    subEl.dispatchEvent(new Event('input', { bubbles: true }));

    bodyEl.focus();
    bodyEl.innerText = body;
    bodyEl.dispatchEvent(new Event('input', { bubbles: true }));

    return { ok: true };
  }, to, subject, body);

  if (!filled.ok) throw new Error('Unable to fill compose fields');

  await page.keyboard.press('Enter'); // commit recipient chip

  if (files.length) {
    const fileInputs = await page.$$('input[type="file"]');
    if (!fileInputs.length) throw new Error('File input not found');
    let attached = false;
    for (const fi of fileInputs) {
      try {
        await fi.uploadFile(...files);
        attached = true;
        break;
      } catch {}
    }
    if (!attached) throw new Error('Attachment upload failed');
    await sleep(3000);
  }

  // strict validation before send
  const checks = await page.evaluate((to, requestedFiles) => {
    const vis = (el) => {
      if (!el) return false;
      const r = el.getBoundingClientRect();
      const s = getComputedStyle(el);
      return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
    };

    const toEl = [...document.querySelectorAll('input[aria-label="To recipients"], input[aria-label*="To"]')].find(vis);
    const subEl = [...document.querySelectorAll('input[name="subjectbox"]')].find(vis);
    const dialog = [...document.querySelectorAll('div[role="dialog"]')].find(vis) || document.body;
    const text = dialog.innerText || '';

    const recipientTokens = [...dialog.querySelectorAll('span[email], div[email]')].map((e) => (e.getAttribute('email') || '').toLowerCase());
    const toValue = (toEl?.value || '').toLowerCase();

    const toOk = toValue.includes(to.toLowerCase()) || recipientTokens.includes(to.toLowerCase()) || text.toLowerCase().includes(to.toLowerCase());
    const subjectValue = (subEl?.value || '').trim();
    const subjectOk = subjectValue.length > 0;

    const attachmentMatches = requestedFiles.map((name) => ({
      name,
      present: text.includes(name),
    }));
    const attachmentOk = attachmentMatches.every((item) => item.present);

    return { toOk, subjectOk, attachmentOk, attachmentMatches, subjectValue };
  }, to, bases);

  if (!checks.toOk || !checks.subjectOk || !checks.attachmentOk) {
    throw new Error(`Validation failed: ${JSON.stringify(checks)}`);
  }

  const clickedSend = await page.evaluate(() => {
    const vis = (el) => {
      if (!el) return false;
      const r = el.getBoundingClientRect();
      const s = getComputedStyle(el);
      return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
    };
    const btn = [...document.querySelectorAll('div[role="button"][aria-label^="Send"], div[role="button"][data-tooltip^="Send"], div[role="button"][aria-label^="傳送"]')].find(vis);
    if (!btn) return false;
    btn.click();
    return true;
  });
  if (!clickedSend) throw new Error('Send button not found');

  try {
    await page.waitForFunction(() => /Message sent|已傳送|郵件已傳送/i.test(document.body.innerText), { timeout: 12000 });
  } catch {
    // Toast can be missed on some Gmail UI states. Continue to hard verification in Sent.
  }

  // verify in sent by unique subject
  await page.goto('https://mail.google.com/mail/u/0/#search/in%3Asent%20subject%3A%22' + encodeURIComponent(subject) + '%22', { waitUntil: 'domcontentloaded' });
  await sleep(4000);
  const found = await page.evaluate((subject) => {
    const txt = document.body.innerText || '';
    if (txt.includes(subject)) return true;
    return [...document.querySelectorAll('tr.zA')].some((row) => (row.innerText || '').includes(subject));
  }, subject);
  if (!found) throw new Error('Sent verification failed: subject not found');

  console.log('EMAIL_SENT_OK');
  console.log(`SUBJECT=${subject}`);
  console.log(`TO=${to}`);
  for (const base of bases) console.log(`FILE_NAME=${base}`);

  await browser.disconnect();
})();


path: references/drive-fallback.md
content: # Google Drive Fallback Delivery

Use this when attachment delivery is blocked or likely to be blocked.

## Policy

- Prefer Drive links for executable-looking archives and skill bundles
- Do not claim delivery until recipient confirms access

## Procedure

1. Upload file to Google Drive
2. Set sharing to target recipient (or anyone with link, as requested)
3. Copy share URL
4. Send link to recipient with filename and checksum/size when helpful

## Suggested message template

- `File delivery fallback via Drive:`
- `<share-link>`
- `Filename: <name>`
- `Reason: email attachment blocked by provider security filter`

## Safety

- Verify link opens and permission is correct before sending
- Avoid making sensitive files public unless explicitly approved


path: references/receipts.md
content: # Receipts (Agent-Browser + Chrome Debug + CDP Gmail)

## 1) Agent-browser showed unauthenticated Gmail state

Observed repeatedly from `agent-browser open https://mail.google.com`:

- URL landed on Google sign-in
- Snapshot heading: `Sign in`
- Required manual login in visible desktop browser session

## 2) Shell script restart receipt

Command:

```bash
/home/little7/.openclaw/workspace/scripts/restart_debug_chrome.sh
```

Observed output:

- `==> Restarting Chrome debug session`
- `DISPLAY=:12.0`
- `==> DevTools is up`
- `Endpoint: http://127.0.0.1:9222/json/version`

## 3) CDP session proof (logged-in inbox)

Using Puppeteer CDP connection to `http://127.0.0.1:9222`:

- URL: `https://mail.google.com/mail/u/0/#inbox`
- Title example observed: `Inbox - [REDACTED_SECRET] - Gmail`

## 4) Final send + verification receipt

Terminal result from the successful run:

- `EMAIL_SENT_OK`
- `SUBJECT=TC20260413_0441.txt 202604130320`
- `TO=[REDACTED_SECRET]`
- `FILE=/home/little7/.openclaw/media/inbound/TC20260413_0441.txt`

The flow verified Sent by unique subject before claiming success.

## 5) Attachment block receipt

User-confirmed Gmail compose state showed:

- `Blocked for security reasons!`
- Example blocked item: `cdp-gmail-delivery.skill.zip`

Operational change recorded: use Google Drive share-link fallback (and prefer Drive-first for skill bundles).

## 6) Script/repo context to include in publication

- Local scripts used:
  - `/home/little7/.openclaw/workspace/scripts/restart_debug_chrome.sh`
  - `/home/little7/.openclaw/workspace/tmp/xrdp_chrome_debug_setup.sh`
- Repository context where XRDP Chrome debug script appears in prior work:
  - `https://github.com/joustonhuang/unifai`
  - path mention: `scripts/xrdp_chrome_debug_setup.sh`


path: references/troubleshooting.md
content: # Troubleshooting

## Runtime dependency missing (`Unable to load puppeteer-core`)

Symptoms:
- send script exits before connecting to Chrome

Actions:
1. Run `bash skills/cdp-gmail-delivery/scripts/install_runtime.sh`
2. Confirm `skills/cdp-gmail-delivery/.runtime/pupp-mail/node_modules/puppeteer-core` exists
3. Retry the send command

## CDP endpoint unreachable (`ECONNREFUSED 127.0.0.1:9222`)

Symptoms:
- send script fails before opening Gmail

Actions:
1. Run `scripts/restart_debug_chrome.sh`
2. Confirm endpoint:
   - `curl -fsS http://127.0.0.1:9222/json/version`
3. If the endpoint is still unavailable, ask the human operator to help restore the visible debug Chrome session
4. Retry send command

## Gmail still opens Sign-in page in automation

Symptoms:
- browser automation sees Google login page

Actions:
1. Use visible desktop Chrome session started by `restart_debug_chrome.sh`
2. Ask user to log in manually there
3. Re-run send script after login

## Validation failed: duplicate attachment

Symptoms:
- error includes `attachmentOk:false` / occurrence > 1

Actions:
1. Discard existing draft in Gmail
2. Re-open fresh compose
3. Attach only once and retry

## Attachment blocked by Gmail for security reasons

Symptoms:
- Gmail blocks the draft or send step
- Gmail reports a security or harmful-content issue

Common causes:
- executable or script-like attachment types
- blocked files inside archives
- password-protected archives
- documents with malicious macros

Actions:
1. Stop retrying the same blocked file
2. Confirm whether the file type or archive contents match Gmail's blocked classes
3. Tell the human operator this is a limitation of Gmail attachment sending for this skill
4. Ask for another delivery path or a safer export format

## Attachment too large for Gmail

Symptoms:
- attachment cannot be added normally
- Gmail moves large content out of the normal attachment path

Actions:
1. For personal Gmail, keep total attachment size at or under 25 MB
2. For Workspace accounts, remember admin-defined limits may differ
3. Tell the human operator this is a limitation of Gmail attachment sending for this skill
4. Ask for another delivery path

## Raw HTML email body requested

Symptoms:
- the user wants a true HTML email body or newsletter rendered inline in Gmail

Reality:
- this skill does not reliably support raw HTML body injection into Gmail compose
- treat this as a limitation of the current Gmail CDP compose workflow

Actions:
1. Do not claim inline HTML email body support
2. Tell the human operator this is a limitation of this skill
3. Send plain-text summary/body plus `.html` attachment when acceptable
4. If true inline HTML is required, route the request to a future Gmail API / MIME-based workflow

## Send toast not shown but maybe sent

Symptoms:
- script times out waiting for "Message sent"

Actions:
1. Search Sent folder by unique subject
2. If found in Sent, treat as sent
3. If not found, retry from fresh compose


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)