diff --git a/.gitignore b/.gitignore index a3f594a..09447b1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ coverage/ !.dev.vars.example .env* *.log +client_secret*.json +credentials.json +token.json +gmail-state/ .DS_Store diff --git a/README.ja.md b/README.ja.md index 671dea2..4b04fa3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -15,7 +15,7 @@ ## エージェントにセットアップしてもらう -ターミナルを操作でき、自分の Cloudflare アカウントを利用できるエージェントへ、以下の**どちらか一方**を渡してください。新規導入には Cloudflare の [cf CLI](https://developers.cloudflare.com/cf/get-started/)(現在ベータ)を使えます。Wrangler の手順も維持しており、同じ Worker コードと SQLite を使います。Gmail が未接続の場合は別途ログインします。 +ターミナルを操作でき、自分のアカウントを利用できるエージェントへ、以下の**どちらか一方**を渡してください。新規導入には Cloudflare の [cf CLI](https://developers.cloudflare.com/cf/get-started/)(現在ベータ)を使えます。Wrangler の手順も維持しており、同じ Worker コードと SQLite を使います。標準の Gmail 連携は、同梱 CLI と**自分の Google Cloud プロジェクト・Desktop OAuth クライアント**を使います。対応する既存 Gmail コネクターがあれば[代替経路](docs/gmail-connectors.ja.md)として使えます。 ### cf CLI @@ -23,14 +23,22 @@ 以下の手順に従って、cf を使って redpixels をセットアップしてください。 https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-cf.ja.md -自分の Cloudflare アカウントへデプロイし、エージェントが作る HTML Gmail -下書きのフローに接続してください。cf の既存の認証を使い、秘密の値は -安全に生成・保存し、無関係な設定を保持してください。作業は直接進め、 -必要なログイン・アカウント選択・不足情報だけを確認してください。 - -認証済みの自分の Gmail アドレスに、用途を明記したセットアップ確認メールを -ちょうど 1 通送って構いません。使用した CLI、Worker の URL、下書き作成の -永続設定の保存場所、検証結果、Webhook の設定状態を報告してください。 +自分の Cloudflare アカウントへデプロイし、次の手順でローカル Gmail CLI を +設定してください。 +https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-gmail.ja.md + +適切な既存設定を再利用するか、個人利用の専用 Google Cloud プロジェクトと +Desktop OAuth クライアントを作ってください。有効化するのは Gmail API だけで、 +課金・有料トライアル・割当増加は設定しないでください。cf の認証を再利用し、 +他のアプリや設定を保持してください。秘密の値は Git やチャットへ出さず保護して +保存します。作業は直接進め、必要なログイン・パスキー・規約同意・OAuth 許可は +私に任せてください。 + +認証済みの自分宛てに確認用下書きを 1 通作り、保存された HTML の画像を検証し、 +送信は私が確認して行います。検証した作成フローを今後も使えるよう保存し、 +送信後の手動追跡開始と即時開始の違いを説明してください。通常の Gmail 作成 +メールが自動追跡されるとは説明しないでください。CLI、Worker URL、設定場所、 +確認できた結果、Webhook の状態を報告してください。 ``` ### Wrangler @@ -39,22 +47,34 @@ https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-cf.ja 以下の手順に従って、Wrangler を使って redpixels をセットアップしてください。 https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-wrangler.ja.md -自分の Cloudflare アカウントへデプロイし、エージェントが作る HTML Gmail -下書きのフローに接続してください。Wrangler の既存の認証を使い、秘密の値は -安全に生成・保存し、無関係な設定を保持してください。作業は直接進め、 -必要なログイン・アカウント選択・不足情報だけを確認してください。 - -認証済みの自分の Gmail アドレスに、用途を明記したセットアップ確認メールを -ちょうど 1 通送って構いません。使用した CLI、Worker の URL、下書き作成の -永続設定の保存場所、検証結果、Webhook の設定状態を報告してください。 +自分の Cloudflare アカウントへデプロイし、次の手順でローカル Gmail CLI を +設定してください。 +https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-gmail.ja.md + +適切な既存設定を再利用するか、個人利用の専用 Google Cloud プロジェクトと +Desktop OAuth クライアントを作ってください。有効化するのは Gmail API だけで、 +課金・有料トライアル・割当増加は設定しないでください。Wrangler の認証を再利用し、 +他のアプリや設定を保持してください。秘密の値は Git やチャットへ出さず保護して +保存します。作業は直接進め、必要なログイン・パスキー・規約同意・OAuth 許可は +私に任せてください。 + +認証済みの自分宛てに確認用下書きを 1 通作り、保存された HTML の画像を検証し、 +送信は私が確認して行います。検証した作成フローを今後も使えるよう保存し、 +送信後の手動追跡開始と即時開始の違いを説明してください。通常の Gmail 作成 +メールが自動追跡されるとは説明しないでください。CLI、Worker URL、設定場所、 +確認できた結果、Webhook の状態を報告してください。 ``` 対応する HTTPS Webhook 受信先がある場合は、その URL と、通知を `first`(初回)か `every`(毎回)のどちらにするかを追記してください。受信先がなければ、履歴の追跡まで設定し、Webhook は無効と報告します。API キーはプロンプトへ貼らず、エージェントのシークレット管理機能に保存します。 -[エージェント向けセットアップガイド](docs/agent-setup.ja.md)に、デプロイ、秘密の設定、下書き作成フローへの永続的な組み込み、実環境での確認をまとめています。redpixels のデプロイだけで通常の Gmail 作成メールが追跡されるわけではなく、下書き作成フローで返されたピクセルを埋め込む必要があります。手動で進める場合は、以下の手順を使えます。 +デプロイ済みなら、[Gmail 設定専用の初期プロンプトと詳細手順](docs/agent-setup-gmail.ja.md)をエージェントへ渡してください。プロジェクト作成、個人 Gmail と Workspace の違い、OAuth 同意、認証情報の保護、復旧、料金、実環境テストまで含みます。Cloudflare 設定とのつなぎ方は[共通ガイド](docs/agent-setup.ja.md)にまとめています。 + +**現在の利用フロー:** エージェントが画像入り下書きを作り、人が Gmail で確認・送信します。Gmail の新規作成画面から書き始めたメールは自動追跡されません。CLI は送信後の手動追跡開始まで通知を抑えるのが既定です。`--armed` なら即時開始できますが、自分のプレビューも数える可能性があります。送信済みの自動検知は未実装で、GCP 設定だけではこれらの制約は解消しません。 ## 数分でデプロイ +この節は追跡 Worker のデプロイ手順です。初回の Gmail OAuth 接続には、別途 Google Console での設定が必要です。 + Cloudflare アカウント、Node.js **22.18 以降または 24 LTS**、[pnpm](https://pnpm.io/installation) 10 が必要です。両 CLI はプロジェクト内にバージョンを固定しており、グローバルインストールは不要です。SQLite Durable Object は設定から準備されるため、手動のデータベース ID、KV 名前空間、外部データベースは不要です。Cloudflare の利用上限・料金は適用されます。[Durable Objects の料金](https://developers.cloudflare.com/durable-objects/platform/pricing/)を確認してください。 ```sh @@ -123,14 +143,50 @@ curl --fail-with-body "$REDPIXELS_URL/v1/messages" \ ## Gmail 下書きへの組み込み +### 標準のセルフホスト設定: ローカル Gmail CLI + +同梱の raw MIME CLI と、**自分の Google Cloud プロジェクト・Desktop OAuth クライアント**を使います。メール登録、ピクセル挿入、Gmail への `message.raw` の送信、保存した HTML MIME の画像確認を行います。下書き専用で、Google の認証情報は Worker に渡しません。エージェント向けの[詳細な Gmail 設定手順](docs/agent-setup-gmail.ja.md)と[手動の CLI ガイド](docs/gmail-cli.ja.md)を用意しています。既存コネクターの内部トークンを取り出して設定しないでください。 + +[一度だけの Desktop OAuth 設定](docs/gmail-cli.ja.md)の後、エージェントから実行できます。 + +```sh +# REDPIXELS_URL と REDPIXELS_API_TOKEN(または REDPIXELS_SECRETS_FILE)を手元で設定済み +pnpm --silent gmail draft --to alice@example.com --subject 'Hello' \ + --html examples/email.html --operation hello-001 +``` + +JSON で追跡ID・下書きID・`state:"verified"` を返します。既定では追跡開始前のため、**人が Gmail で確認して Send を押してから**実行します。 + +```sh +pnpm --silent gmail arm --operation hello-001 --confirm-sent +``` + +開始前の取得は `before_armed` として履歴に残り、通知はしません。開始後の自分のプレビューやプロキシキャッシュは完全には除外できません。Web / iOS の確認には新しいメールをそれぞれ使ってください。[CLI 設定・OAuth 保存先・復旧・制限・手動テスト](docs/gmail-cli.ja.md)に手順をまとめています。OAuth は Google 公式の `google-auth-library`、保存先は OS の認証情報ストアが既定です。ヘッドレス向けの保護ファイル方式は明示的に選択します。 + +2026-10-16 の実接続では、この CLI の Desktop OAuth、macOS キーチェーンへの保存、raw MIME の下書き2通の画像確認まで成功しました。人が送信・追跡開始した後、別アカウントの Gmail Web / iOS で別々のテストメールを開き、各1件の追加取得と、同じイベント ID の署名検証済み Webhook(HTTP 204)を確認しています。[試験範囲と残る未確認事項](docs/gmail-cli.ja.md#手動の受入テスト)も参照してください。 + +### 代替: 既存の Gmail 接続を再利用 + +MIME を保持して保存済み raw メッセージを読み戻せる Gmail 接続があれば、追加の GCP・OAuth 設定を省略できます。`"armed":false` で登録し、`pixel_html` を入れて MIME / raw バイト列経路で下書きを作り、実際に保存した画像を確認します。両方の ID を保持し、人が送信してから[追跡を開始](#下書きプレビューと追跡開始)します。`htmlBody` だけで画像が保持されるとは限りません。[対応確認と手動テスト](docs/gmail-connectors.ja.md)を参照してください。 + +2026-10-16 に、既存の Codex Gmail コネクターの `mime_type:"text/html"` と `body.base64_url_content` で未送信下書きを作り、raw HTML が画像を含めて入力と一致することを確認しました。この試験は下書き保存までで、送信と受信者の開封は上記 CLI の別試験で確認しています。 + +### GCP の料金と将来の Managed 版 + +現在のローカル CLI では、標準枠内の Gmail API に追加料金はなく、**請求先・カード登録も不要**です。GCP のサーバーや Pub/Sub は使いません。通常の下書き作成は保存後の検証を含め 31 quota units で、1 日 100 通なら約 3,100 units(再試行や追加確認を除く)です。公表されている日次の閾値は 1 プロジェクト 8,000 万 units です。Google は 2026 年後半に標準枠超過の課金を予定し、適用前に最低 90 日間の告知を行うとしています。永久・無制限の無料保証ではありません。Cloudflare の利用料、既存の Workspace やエージェントの契約料は別です。[公式の割当・料金方針](https://developers.google.com/workspace/gmail/api/reference/quota)と[設定ガイドの料金説明](docs/agent-setup-gmail.ja.md#手間と費用)を参照してください。 + +将来の Managed 版では、運営側がデプロイと OAuth アプリを用意し、利用者は Gmail の許可だけで接続する形にできます。現在のリリースには含まれません。ホスティングだけで Gmail の作成画面への自動挿入や送信の自動検知が可能になるわけではなく、別途連携の実装が必要です。 + +### 既存の raw MIME フローと低水準サンプル + 1. redpixels にメールを登録します。 2. HTML MIME パートに `pixel_html` を追加します。 3. 完成した MIME メッセージを base64url でエンコードし、Gmail の `users.drafts.create` を呼びます。 4. Gmail で確認して送信します。下書きを置き換える際も画像を保持してください。 -MIME と base64url の形式は [Google の下書きガイド](https://developers.google.com/workspace/gmail/api/guides/drafts)に記載されています。redpixels 本体は受信箱を読まず、Gmail OAuth 権限を取得せず、メールを送信しません。 +MIME と base64url の形式は [Google の下書きガイド](https://developers.google.com/workspace/gmail/api/guides/drafts)に記載されています。Worker は受信箱を読まず、Gmail OAuth 権限を取得しません。ローカル CLI は端末上で下書き用の認証を行います。いずれもメールは送信しません。 -依存ライブラリ不要の [Node サンプル](examples/gmail-draft.mts)で、送信せずに下書きを作成できます。 +短期のアクセストークンを手元で渡す、依存ライブラリ不要の[低水準サンプル](examples/gmail-draft.mts)も維持しています。トークン更新や保存後の画像検証はしないため、直接 API に接続する場合には上の CLI を推奨します。 ```sh export GMAIL_ACCESS_TOKEN='' @@ -141,11 +197,11 @@ export GMAIL_TEXT='Hello Alice!' node --experimental-strip-types examples/gmail-draft.mts examples/email.html ``` -実際に使う際は [examples/email.html](examples/email.html) を自分の HTML ファイルに置き換えてください。既存の AI アシスタントの Gmail 認証を利用します。未取得の場合は [Google の権限ガイド](https://developers.google.com/workspace/gmail/api/auth/scopes)を確認して先に Gmail API の OAuth を設定してください。redpixels のデプロイでは Gmail 認証は提供されません。Gmail の認証情報を使うのは手元のサンプルだけです。Worker の環境変数には入れません。Gmail のエラーやタイムアウト時には、復旧のため redpixels の ID を表示・保持します。サンプルを再実行すると Gmail 下書きが重複する場合があります。`REDPIXELS_IDEMPOTENCY_KEY` が重複を防ぐのは redpixels の登録だけであり、Gmail 下書きの作成ではありません。 +実際に使う際は [examples/email.html](examples/email.html) を自分の HTML ファイルに置き換えてください。対応する認証フローで明示的に得たトークンを使い、既存コネクターの内部トークンを取り出さないでください。権限は [Google のガイド](https://developers.google.com/workspace/gmail/api/auth/scopes)を参照してください。redpixels のデプロイでは Gmail 認証は提供されません。Gmail の認証情報を使うのは手元のサンプルだけです。Worker の環境変数には入れません。Gmail のエラーやタイムアウト時には、復旧のため redpixels の ID を表示・保持します。サンプルを再実行すると Gmail 下書きが重複する場合があります。`REDPIXELS_IDEMPOTENCY_KEY` が重複を防ぐのは redpixels の登録だけであり、Gmail 下書きの作成ではありません。 ### 下書きプレビューと追跡開始 -初期設定では、登録直後から追跡します。送信前のプレビューでも画像が取得される可能性があります。送信後に処理を実行できる場合は `"armed":false` で登録し、送信後に次を呼びます。 +HTTP API は登録直後からの追跡が既定で、ローカル CLI は追跡開始前の下書きが既定です。送信前のプレビューでも画像が取得される可能性があります。API のフローで送信後に処理を実行できる場合は `"armed":false` で登録し、送信後に次を呼びます。 ```sh curl --fail-with-body -X POST "$REDPIXELS_URL/v1/messages//arm" \ @@ -325,6 +381,8 @@ pnpm run check:cli-builds `pnpm check` は Worker、テスト、Node サンプル、両 CLI の設定を strict な TypeScript で検査します。ランタイムの型は固定版の Wrangler で生成し、生成ファイルは Git 管理しません。`src/types.ts` に開封記録・フラグ・API 応答・Webhook の型を、`src/storage-types.ts` に SQLite の行の型を分けています。型は既存の実行時入力検証の代わりにはなりません。 +Node のテストには CLI の raw MIME・保存後の画像検証・画像除去・公式 SDK の PKCE/state/トークン更新・本人確認・保存権限・結果不明時の復旧も含みます。HTTP 境界は模擬しており、新CLIの実 OAuth や受信者の閲覧を確認したことにはなりません。 + テストは Workers ランタイムと実際のローカル SQLite Durable Objects で、同時アクセス時の回数・初回通知、認証、重複登録、ページ分割、判定、所有者情報、再起動、削除、署名、再試行上限を確認します。Node のテストは受信署名検証と MIME 構築を確認します。CI では両 CLI のデプロイ dry run と、Worker の識別子・SQLite バインディング・実行コードの一致を確認します。これらで本番デプロイ、Gmail 配信、モバイル編集、実際の MPP 精度を検証したことにはなりません。 [構成の説明](docs/architecture.md)も参照してください。この版は 1 人向けのセルフホスト OSS です。認証・保存・判定・通知配信を分離し、将来のテナント・API キー解決や利用量計測を追加しやすくしています。課金や複数ユーザー管理は含みません。 diff --git a/README.md b/README.md index 2f5adaf..4c4030c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The name plays on “read pixels”: the past tense of “read” sounds like ## Set up with your agent -Choose **one** prompt for an agent with terminal access and permission to use your Cloudflare account. New installations can use Cloudflare's [cf CLI](https://developers.cloudflare.com/cf/get-started/) (currently beta); the Wrangler path remains available. Both use the same Worker code and SQLite storage. Gmail connection is a separate sign-in step if the agent is not connected yet. +Choose **one** prompt for an agent with terminal access and permission to use your accounts. New installations can use Cloudflare's [cf CLI](https://developers.cloudflare.com/cf/get-started/) (currently beta); the Wrangler path remains available. Both use the same Worker code and SQLite storage. The standard self-hosted Gmail path uses the bundled CLI and **your own Google Cloud project and Desktop OAuth client**. A compatible existing Gmail connector is an [optional alternative](docs/gmail-connectors.md). ### cf CLI @@ -23,14 +23,21 @@ Choose **one** prompt for an agent with terminal access and permission to use yo Set up redpixels using cf by following: https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-cf.md -Deploy it to my Cloudflare account and connect it to my agent's HTML Gmail -draft workflow. Reuse existing cf credentials, generate and store secrets -securely, and preserve unrelated settings. Do the work directly; ask only -for required sign-in, account selection, or missing information. - -You may send exactly one clearly labeled setup test email to my authenticated -Gmail address. Report the selected CLI, Worker URL, persistent draft integration -location, test results, and webhook status. +Deploy it to my Cloudflare account, then set up the local Gmail CLI following: +https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-gmail.md + +Reuse appropriate existing settings or create a dedicated Google Cloud project +and Desktop OAuth client for my personal use. Enable only Gmail API; do not +enable billing, start a paid trial, or request increased quotas. Reuse cf +credentials and preserve unrelated apps and settings. Store secrets securely, +outside Git and chat. Do the work directly; leave required sign-in, passkeys, +terms acceptance and OAuth consent to me. + +Create one labeled test draft to my authenticated Gmail address and verify +the saved HTML image. Let me review and press Send. Persist the verified draft +workflow for future requests. Explain manual arming versus immediate tracking; +do not claim automatic tracking of ordinary Gmail-composed mail. Report the +CLI, Worker URL, configuration locations, test evidence and webhook status. ``` ### Wrangler @@ -39,22 +46,33 @@ location, test results, and webhook status. Set up redpixels using Wrangler by following: https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-wrangler.md -Deploy it to my Cloudflare account and connect it to my agent's HTML Gmail -draft workflow. Reuse existing Wrangler credentials, generate and store secrets -securely, and preserve unrelated settings. Do the work directly; ask only -for required sign-in, account selection, or missing information. - -You may send exactly one clearly labeled setup test email to my authenticated -Gmail address. Report the selected CLI, Worker URL, persistent draft integration -location, test results, and webhook status. +Deploy it to my Cloudflare account, then set up the local Gmail CLI following: +https://raw.githubusercontent.com/arkorlab/redpixels/main/docs/agent-setup-gmail.md + +Reuse appropriate existing settings or create a dedicated Google Cloud project +and Desktop OAuth client for my personal use. Enable only Gmail API; do not +enable billing, start a paid trial, or request increased quotas. Reuse Wrangler +credentials and preserve unrelated apps and settings. Store secrets securely, +outside Git and chat. Do the work directly; leave required sign-in, passkeys, +terms acceptance and OAuth consent to me. + +Create one labeled test draft to my authenticated Gmail address and verify +the saved HTML image. Let me review and press Send. Persist the verified draft +workflow for future requests. Explain manual arming versus immediate tracking; +do not claim automatic tracking of ordinary Gmail-composed mail. Report the +CLI, Worker URL, configuration locations, test evidence and webhook status. ``` If you already have a compatible HTTPS webhook receiver, append its URL and whether you want `first` or `every` notifications. Otherwise the agent can finish history tracking and report webhooks as disabled. Keep API keys in your agent's secret store, not in the prompt. -The [agent setup guide](docs/agent-setup.md) covers deployment, secrets, persistent draft integration, and live verification. Deploying redpixels alone does not make ordinary Gmail-composed emails trackable; the drafting workflow must embed the returned pixel. The manual steps follow below. +Already deployed? Give your agent the [Gmail-only setup prompt and detailed procedure](docs/agent-setup-gmail.md). It covers project creation, personal versus Workspace accounts, OAuth consent, protected credentials, recovery, costs, and an end-to-end test. The [common agent guide](docs/agent-setup.md) connects this with Cloudflare deployment. + +**Current workflow:** the agent creates a pixel-bearing draft; you review and send it in Gmail. Ordinary mail started from Gmail's blank compose screen is not automatically tracked. The CLI defaults to suppressing notifications until a manual post-send arming step; `--armed` starts immediately but can count your previews. Gmail Sent detection is not implemented. Google Cloud setup does not remove these limitations. ## Deploy in a few minutes +This section deploys the tracking Worker. First-time Gmail OAuth setup is separate and requires additional Google Console steps. + You need a Cloudflare account, Node.js **22.18+ or 24 LTS**, and [pnpm](https://pnpm.io/installation) 10. Both CLIs are pinned locally; no global CLI installation is needed. SQLite Durable Object storage is provisioned by the configuration, without a manual database ID, KV namespace, or external database. Cloudflare usage limits and charges apply; see [Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/). ```sh @@ -123,14 +141,50 @@ Use a new `Idempotency-Key` per email. Retrying the same key and body returns th ## Gmail draft integration +### Standard self-hosted setup: local Gmail CLI + +Use the bundled raw MIME CLI with **your own Google Cloud project and Desktop OAuth client**. It registers the message, inserts the pixel, submits `message.raw` directly to Gmail, and verifies the saved HTML MIME part. It creates drafts only. The Worker never receives Google credentials. An agent can follow the [complete Gmail setup instructions](docs/agent-setup-gmail.md); manual instructions are in the [CLI guide](docs/gmail-cli.md). Do not extract private tokens from an agent's existing connection to configure it. + +After [one-time Desktop OAuth setup](docs/gmail-cli.md), an agent can run: + +```sh +# REDPIXELS_URL and REDPIXELS_API_TOKEN (or REDPIXELS_SECRETS_FILE) configured locally +pnpm --silent gmail draft --to alice@example.com --subject 'Hello' \ + --html examples/email.html --operation hello-001 +``` + +The JSON result includes the tracking/draft IDs and `state:"verified"`. Drafts default to unarmed: **review and press Send in Gmail**, then run: + +```sh +pnpm --silent gmail arm --operation hello-001 --confirm-sent +``` + +Pre-arm fetches remain in history with `before_armed` but do not notify. Sender previews after arming and proxy caching cannot be eliminated. Use a fresh message for each Web/iOS acceptance test. See the [CLI setup, OAuth storage, recovery, limitations and manual test steps](docs/gmail-cli.md). Google's official `google-auth-library` handles OAuth; OS credential storage is the default, with an explicit protected-file option for headless use. + +In a live trial on 2026-10-16, this CLI completed Desktop OAuth, stored credentials in macOS Keychain, and verified two saved raw-MIME drafts. After human sending and arming, separate recipient Gmail Web/iOS tests each produced a new recorded fetch and a matching signature-verified webhook (HTTP 204). See the [trial scope and remaining gaps](docs/gmail-cli.md#manual-acceptance-test). + +### Optional: reuse an existing Gmail connection + +A connected Gmail tool that preserves MIME content and can read back the saved raw message can avoid a separate Google Cloud/OAuth setup. Register with `"armed":false`, insert `pixel_html`, create through the MIME/raw byte path, and verify the actual saved image before reporting success. Keep both IDs, let the human send, then [arm the message](#draft-previews-and-arming). An `htmlBody` field alone is not proof of compatibility: some connectors strip images. See the [capability checks and manual test](docs/gmail-connectors.md). + +On 2026-10-16, an existing Codex Gmail connector's `mime_type:"text/html"` and `body.base64_url_content` path created an **unsent** draft whose raw HTML matched the input, including the image. That trial verified saved drafts only; sending and recipient opens were tested separately with the CLI above. + +### Google Cloud costs and a future managed edition + +For the current local CLI, standard Gmail API use has no additional charge, and **billing/card registration is not required**. No GCP server or Pub/Sub is used. A normal new draft uses 31 Gmail quota units including saved-draft verification; 100 drafts/day would use about 3,100 units before retries or extra checks. Google's published daily threshold is 80 million units/project. Google plans charges for over-standard usage later in 2026 with at least 90 days' notice; this is not a promise of permanent unlimited free use. Cloudflare usage and any existing Workspace or agent subscription are separate. See the [official quota/pricing policy](https://developers.google.com/workspace/gmail/api/reference/quota) and [setup cost details](docs/agent-setup-gmail.md#costs-and-prerequisites-to-explain). + +A future managed edition could handle deployment and the operator's OAuth application so users only authorize Gmail. It is not included in this release. Hosting alone does not add automatic pixel insertion to Gmail's compose screen or automatic send detection; those need separate integration work. + +### Existing raw-MIME workflows and low-level example + 1. Register the email with redpixels. 2. Insert `pixel_html` into the HTML MIME part. 3. Base64url-encode the complete MIME message and call Gmail `users.drafts.create`. 4. Review and send in Gmail. Preserve the pixel whenever replacing a draft. -[Google's draft guide](https://developers.google.com/workspace/gmail/api/guides/drafts) documents MIME and base64url encoding. redpixels does not read your inbox, obtain Gmail OAuth access, or send mail. +[Google's draft guide](https://developers.google.com/workspace/gmail/api/guides/drafts) documents MIME and base64url encoding. The Worker does not read your inbox or obtain Gmail OAuth access. The local CLI obtains draft access on your machine; neither it nor the Worker sends mail. -The dependency-free [Node example](examples/gmail-draft.mts) creates a draft without sending it: +The existing dependency-free [low-level example](examples/gmail-draft.mts) remains available for workflows supplying a short-lived access token. It does not manage refresh tokens or verify the saved draft; use the CLI or a verified connector for agent use: ```sh export GMAIL_ACCESS_TOKEN='' @@ -141,11 +195,11 @@ export GMAIL_TEXT='Hello Alice!' node --experimental-strip-types examples/gmail-draft.mts examples/email.html ``` -Replace [examples/email.html](examples/email.html) with your own HTML file when ready. Use your assistant's existing Gmail authorization; if you do not have one, configure Gmail API OAuth first using [Google's scope guide](https://developers.google.com/workspace/gmail/api/auth/scopes). A redpixels deployment does not supply Gmail authorization. Gmail credentials are used only by this local example; never put them in Worker variables. On Gmail failure/timeout, the example retains and prints the redpixels ID for recovery. Re-running can create another Gmail draft; `REDPIXELS_IDEMPOTENCY_KEY` deduplicates redpixels registration only, not Gmail draft creation. +Replace [examples/email.html](examples/email.html) with your own HTML file when ready. Use a token explicitly supplied through a supported credential flow; never extract private tokens from a connected agent tool. This low-level example needs separate Gmail API authorization as described in [Google's scope guide](https://developers.google.com/workspace/gmail/api/auth/scopes). A redpixels deployment does not supply Gmail authorization. Gmail credentials are used only by this local example; never put them in Worker variables. On Gmail failure/timeout, the example retains and prints the redpixels ID for recovery. Re-running can create another Gmail draft; `REDPIXELS_IDEMPOTENCY_KEY` deduplicates redpixels registration only, not Gmail draft creation. ### Draft previews and arming -By default tracking starts immediately. A preview can fetch before sending. If your workflow can act after sending, register with `"armed":false`, then call: +The HTTP API defaults to immediate tracking; the local CLI defaults to unarmed drafts. A preview can fetch before sending. If your API workflow can act after sending, register with `"armed":false`, then call: ```sh curl --fail-with-body -X POST "$REDPIXELS_URL/v1/messages//arm" \ @@ -325,6 +379,8 @@ Local development uses local storage and does not deploy. Owner cookies require `pnpm check` runs strict TypeScript checks for the Worker, tests, Node examples, and both CLI configurations. Runtime types are generated by the pinned Wrangler version; the generated file is ignored by Git. `src/types.ts` defines the public open records, flags, API responses, and webhook payload; `src/storage-types.ts` keeps SQLite row types separate. Types do not replace the existing runtime input validation. +Node tests also cover CLI raw-MIME submission/read-back, stripped images, OAuth PKCE/state/refresh with the real Google SDK and mocked HTTP, account checks, private storage and uncertain-write recovery. These do not establish real Gmail OAuth or recipient viewing for the new CLI. + Tests use the Workers runtime and real local SQLite Durable Objects: concurrent counts/first notifications, auth, idempotency, pagination, classification, owner signals, eviction, deletion, signatures and bounded retries. Node tests cover receiver verification and MIME construction. CI builds both CLI paths with deployment dry runs and checks that their Worker identity, SQLite bindings, and runtime bundles match. These do not verify production deployment, Gmail delivery, mobile editing or real MPP accuracy. See [architecture](docs/architecture.md). This release is single-owner self-hosted OSS. Authentication, storage, classification and delivery have separate boundaries for later tenant/key resolution and metering. Billing and multi-user management are outside this release. diff --git a/cli/contracts.mts b/cli/contracts.mts new file mode 100644 index 0000000..f975cc7 --- /dev/null +++ b/cli/contracts.mts @@ -0,0 +1,143 @@ +export const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.compose"; +export const GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me"; +export type Fetcher = (url: string, init?: RequestInit) => Promise; + +export interface OAuthClient { + clientId: string; + clientSecret: string; +} +export interface GmailCredential extends OAuthClient { + version: 1; + email: string; + refreshToken: string; + scopes: string[]; +} +export interface CredentialStore { + read(): Promise; + write(value: GmailCredential): Promise; + remove(): Promise; +} +export interface GmailPart { + mimeType?: string; + body?: { data?: string }; + headers?: { name: string; value: string }[]; + parts?: GmailPart[]; +} +export interface GmailDraft { + id: string; + message: { id: string; payload?: GmailPart }; +} +export type OperationState = + | "registered" + | "creating" + | "created" + | "verified" + | "verification_failed" + | "ambiguous"; +export interface DraftOperation { + version: 1; + operation: string; + fingerprint: string; + account: string; + trackerOrigin: string; + trackingId: string; + pixelUrl: string; + pixelHtml: string; + armed: boolean; + state: OperationState; + draftId?: string; + gmailMessageId?: string; + createdAt: string; + verifiedAt?: string; +} +export interface DraftInput { + operation: string; + to: string; + subject: string; + html: string; + text: string; + armed: boolean; +} +export interface TrackerSettings { + origin: string; + token: string; +} + +export function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Invalid JSON object in service response or local state"); + return value as Record; +} +export function string(value: unknown, label: string): string { + if (typeof value !== "string" || !value || value.length > 16384) + throw new Error(`Invalid ${label}`); + return value; +} +export function email(value: unknown): string { + const address = string(value, "email address"); + if ( + address.length > 254 || + !/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test( + address, + ) + ) + throw new Error( + "Use one bare email address, without display names or lists", + ); + return address; +} +export function operationName(value: string): string { + if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) + throw new Error( + "Operation must be 1–128 letters, digits, dots, underscores or hyphens", + ); + return value; +} +export class HttpFailure extends Error { + readonly status: number; + constructor(status: number, service: string) { + super(`${service} returned HTTP ${status}`); + this.status = status; + } +} +export async function requestJson( + fetcher: Fetcher, + url: string, + init: RequestInit, + service: string, +): Promise> { + let response: Response; + try { + response = await fetcher(url, { + ...init, + redirect: "error", + signal: init.signal ?? AbortSignal.timeout(30000), + }); + } catch { + throw new Error(`${service} request failed or timed out`); + } + if (!response.ok) { + await response.body?.cancel(); + throw new HttpFailure(response.status, service); + } + // Never include response bodies or credential values in errors. + const reader = response.body?.getReader(); + if (!reader) throw new Error(`${service} returned an empty response`); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > 12 * 1024 * 1024) { + await reader.cancel(); + throw new Error(`${service} response exceeds 12 MiB`); + } + chunks.push(value); + } + try { + return object(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + throw new Error(`${service} returned invalid JSON`); + } +} diff --git a/cli/drafts.mts b/cli/drafts.mts new file mode 100644 index 0000000..b6bfd81 --- /dev/null +++ b/cli/drafts.mts @@ -0,0 +1,397 @@ +import { createHash } from "node:crypto"; +import { parse, type DefaultTreeAdapterMap } from "parse5"; +import { createMime } from "../examples/mime.mts"; +import { + GMAIL_API, + HttpFailure, + email, + object, + operationName, + requestJson, + string, + type CredentialStore, + type DraftInput, + type DraftOperation, + type Fetcher, + type TrackerSettings, +} from "./contracts.mts"; +import { access } from "./oauth.mts"; +import { OperationStore } from "./storage.mts"; + +export interface DraftServices { + fetcher: Fetcher; + credentials: CredentialStore; + operations: OperationStore; + tracker: TrackerSettings; +} + +export function trackerOrigin(value: string): string { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) + throw new Error( + "REDPIXELS_URL must be an HTTPS origin without path, credentials, query or fragment", + ); + return url.origin; +} +export function validateInput(input: DraftInput): void { + operationName(input.operation); + email(input.to); + if ( + !input.subject || + input.subject.length > 1000 || + /[\x00-\x1f\x7f]/.test(input.subject) + ) + throw new Error( + "Subject must be 1–1000 characters without control characters", + ); + if ( + !input.html.trim() || + Buffer.byteLength(input.html) > 2 * 1024 * 1024 || + Buffer.byteLength(input.text) > 2 * 1024 * 1024 + ) + throw new Error( + "Provide HTML; HTML and plain text must each be at most 2 MiB", + ); +} +export function hasPixel(html: string, expected: string): boolean { + function visit(node: DefaultTreeAdapterMap["node"]): boolean { + if ("tagName" in node) { + // Template contents, comments, script text and escaped markup are not images. + if (node.tagName === "template") return false; + if (node.tagName === "img") { + const src = node.attrs.find( + (attribute) => attribute.name === "src", + )?.value; + if (src === expected) return true; + if (src) + try { + const url = new URL(src); + if ( + url.protocol === "https:" && + !url.username && + !url.password && + /(?:^|\.)googleusercontent\.com$/i.test(url.hostname) && + decodeURIComponent(url.hash.slice(1)) === expected + ) + return true; + } catch { + /* An invalid URL is not the expected pixel. */ + } + } + } + return "childNodes" in node && node.childNodes.some(visit); + } + return visit(parse(html)); +} +function htmlParts(value: unknown, depth = 0): string[] { + if (depth > 20) throw new Error("Gmail MIME nesting is too deep"); + const part = object(value); + const result: string[] = []; + if (part.mimeType === "text/html") { + const data = object(part.body).data; + if (typeof data !== "string" || !/^[a-zA-Z0-9_-]*={0,2}$/.test(data)) + throw new Error("Gmail did not return an inline HTML MIME body"); + result.push(Buffer.from(data, "base64url").toString("utf8")); + } + if (part.parts !== undefined) { + if (!Array.isArray(part.parts) || part.parts.length > 100) + throw new Error("Invalid Gmail MIME parts"); + for (const child of part.parts) result.push(...htmlParts(child, depth + 1)); + } + return result; +} +export function verifyStoredDraft( + value: unknown, + operation: DraftOperation, +): string { + const draft = object(value); + if (draft.id !== operation.draftId) + throw new Error("Gmail returned a different draft"); + const message = object(draft.message); + const payload = object(message.payload); + if (!Array.isArray(payload.headers)) + throw new Error("Gmail draft headers are missing"); + const from = payload.headers + .map(object) + .filter( + (header) => + typeof header.name === "string" && header.name.toLowerCase() === "from", + ); + if (from.length !== 1) + throw new Error("Gmail draft has no unique From header"); + const header = string(from[0]!.value, "From header").trim(); + const address = /<([^<>\s]+)>$/.exec(header)?.[1] || header; + if (email(address).toLowerCase() !== operation.account.toLowerCase()) + throw new Error( + "Gmail draft sender does not match the authenticated account", + ); + if (!htmlParts(payload).some((html) => hasPixel(html, operation.pixelUrl))) + throw new Error( + "Saved Gmail draft is missing the tracking image; do not send it", + ); + return string(message.id, "Gmail message ID"); +} +function headers(token: string): Record { + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; +} +export function result(operation: DraftOperation) { + return { + operation: operation.operation, + account: operation.account, + tracking_id: operation.trackingId, + pixel_url: operation.pixelUrl, + gmail_draft_id: operation.draftId ?? null, + gmail_message_id: operation.gmailMessageId ?? null, + state: operation.state, + pixel_verified_at: operation.verifiedAt ?? null, + armed: operation.armed, + gmail_url: `https://mail.google.com/mail/u/?authuser=${encodeURIComponent(operation.account)}#drafts`, + next_step: operation.armed + ? "Review and send in Gmail. Draft/Sent previews may record fetches or fill proxy caches." + : `Review and send in Gmail, then run: pnpm gmail arm --operation ${operation.operation} --confirm-sent`, + }; +} +async function checkDraft( + services: DraftServices, + operation: DraftOperation, + accessToken: string, + saveFailure = true, +): Promise { + try { + const saved = await requestJson( + services.fetcher, + `${GMAIL_API}/drafts/${encodeURIComponent(operation.draftId!)}?format=full`, + { headers: headers(accessToken) }, + "Gmail draft verification", + ); + operation.gmailMessageId = verifyStoredDraft(saved, operation); + operation.state = "verified"; + operation.verifiedAt = new Date().toISOString(); + await services.operations.write(operation); + return operation; + } catch (error) { + if (saveFailure) { + operation.state = "verification_failed"; + delete operation.verifiedAt; + await services.operations.write(operation); + } + throw error; + } +} + +export async function createTrackedDraft( + services: DraftServices, + input: DraftInput, +) { + validateInput(input); + const origin = trackerOrigin(services.tracker.origin); + const { accessToken, credential } = await access( + services.credentials, + services.fetcher, + ); + return services.operations.lock(input.operation, async () => { + const fingerprint = createHash("sha256") + .update( + JSON.stringify([ + origin, + credential.email, + input.operation, + input.to, + input.subject, + input.html, + input.text, + input.armed, + ]), + ) + .digest("hex"); + let operation = await services.operations.read(input.operation); + if (operation && operation.fingerprint !== fingerprint) + throw new Error( + "Operation was already used with different input or account; choose a new operation", + ); + if (operation?.state === "verified") return result(operation); + if (operation?.state === "creating" || operation?.state === "ambiguous") + throw new Error( + "Gmail creation outcome is uncertain; inspect Gmail and use verify --operation OP --draft-id ID, without creating another draft", + ); + if (!operation) { + const registered = await requestJson( + services.fetcher, + `${origin}/v1/messages`, + { + method: "POST", + headers: { + ...headers(services.tracker.token), + "Idempotency-Key": `gmail-${input.operation}`, + }, + body: JSON.stringify({ + recipient: input.to, + subject: input.subject, + external_id: `gmail-${input.operation}`, + armed: input.armed, + }), + }, + "redpixels registration", + ); + const id = string(registered.id, "redpixels message ID"); + const pixelUrl = string(registered.pixel_url, "pixel URL"); + const pixelHtml = string(registered.pixel_html, "pixel HTML"); + if ( + !/^[0-9a-f-]{36}$/i.test(id) || + pixelUrl !== `${origin}/p/${id}.gif` || + !hasPixel(pixelHtml, pixelUrl) + ) + throw new Error("redpixels returned an invalid tracking image"); + operation = { + version: 1, + operation: input.operation, + fingerprint, + account: credential.email, + trackerOrigin: origin, + trackingId: id, + pixelUrl, + pixelHtml, + armed: input.armed, + state: "registered", + createdAt: new Date().toISOString(), + }; + await services.operations.write(operation); + } + if (!operation.draftId) { + const mime = createMime({ + from: credential.email, + to: input.to, + subject: input.subject, + html: input.html, + text: input.text, + pixelHtml: operation.pixelHtml, + }); + // Catch malformed input HTML that would turn the inserted image into text/comment. + const trackedHtml = /<\/body\s*>/i.test(input.html) + ? input.html.replace( + /<\/body\s*>/i, + () => `${operation.pixelHtml}`, + ) + : input.html + operation.pixelHtml; + if (!hasPixel(trackedHtml, operation.pixelUrl)) + throw new Error( + "Input HTML prevents the tracking image from rendering; fix it before creating a draft", + ); + operation.state = "creating"; + await services.operations.write(operation); + try { + const created = await requestJson( + services.fetcher, + `${GMAIL_API}/drafts`, + { + method: "POST", + headers: headers(accessToken), + body: JSON.stringify({ + message: { raw: Buffer.from(mime).toString("base64url") }, + }), + }, + "Gmail draft creation", + ); + operation.draftId = string(created.id, "Gmail draft ID"); + operation.gmailMessageId = string( + object(created.message).id, + "Gmail message ID", + ); + } catch (error) { + operation.state = + error instanceof HttpFailure && + error.status >= 400 && + error.status < 500 && + error.status !== 408 + ? "registered" + : "ambiguous"; + await services.operations.write(operation); + throw error; + } + operation.state = "created"; + try { + await services.operations.write(operation); + } catch { + throw new Error( + `Draft created but journal write failed; retain Gmail draft ${operation.draftId} and redpixels ${operation.trackingId}; do not recreate it`, + ); + } + } + return result(await checkDraft(services, operation, accessToken)); + }); +} + +export async function verifyDraft( + services: DraftServices, + name: string, + draftId?: string, +) { + operationName(name); + const { accessToken, credential } = await access( + services.credentials, + services.fetcher, + ); + return services.operations.lock(name, async () => { + const operation = await services.operations.read(name); + if (!operation) throw new Error("Unknown operation"); + if (operation.account.toLowerCase() !== credential.email.toLowerCase()) + throw new Error("Operation belongs to a different Gmail account"); + const candidate = draftId ? { ...operation, draftId } : operation; + if (!candidate.draftId) + throw new Error("Provide --draft-id after inspecting Gmail"); + return result( + await checkDraft( + services, + candidate, + accessToken, + !draftId || draftId === operation.draftId, + ), + ); + }); +} +export async function armDraft( + services: DraftServices, + name: string, + confirmedSent: boolean, +) { + if (!confirmedSent) + throw new Error( + "Press Send in Gmail first, then pass --confirm-sent; the CLI does not inspect Sent mail", + ); + return services.operations.lock(name, async () => { + const operation = await services.operations.read(name); + if (!operation || operation.state !== "verified") + throw new Error("A verified draft operation is required before arming"); + if (operation.trackerOrigin !== trackerOrigin(services.tracker.origin)) + throw new Error("Operation belongs to a different redpixels deployment"); + if (!operation.armed) { + await requestJson( + services.fetcher, + `${operation.trackerOrigin}/v1/messages/${operation.trackingId}/arm`, + { + method: "POST", + headers: headers(services.tracker.token), + }, + "redpixels arming", + ); + operation.armed = true; + await services.operations.write(operation); + } + return { + ...result(operation), + sent_status: "user_confirmed_not_verified", + next_step: + "Tracking is armed. Recipient image fetches can now notify; opening your Sent copy may also record a fetch.", + }; + }); +} diff --git a/cli/gmail.mts b/cli/gmail.mts new file mode 100644 index 0000000..497c572 --- /dev/null +++ b/cli/gmail.mts @@ -0,0 +1,295 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { readFile, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { + object, + string, + type CredentialStore, + type Fetcher, +} from "./contracts.mts"; +import { + armDraft, + createTrackedDraft, + result, + trackerOrigin, + verifyDraft, + type DraftServices, +} from "./drafts.mts"; +import { authorize, desktopClient } from "./oauth.mts"; +import { + FileCredentialStore, + KeychainCredentialStore, + OperationStore, + gmailHome, + privateDirectory, + readPrivateJson, +} from "./storage.mts"; + +const HELP = `redpixels Gmail CLI — creates drafts, never sends email + +pnpm gmail auth login --client /path/to/desktop-oauth-client.json [--no-browser] [--port 8765] +pnpm gmail auth status +pnpm gmail auth logout [--revoke] +pnpm gmail draft --to recipient@example.com --subject 'Hello' --html email.html [--text email.txt] [--operation logical-email-id] [--armed] +pnpm gmail verify --operation ID [--draft-id GMAIL_DRAFT_ID] +pnpm gmail status --operation ID +pnpm gmail arm --operation ID --confirm-sent + +Drafts default to unarmed. Review and press Send in Gmail, then arm the operation. +--armed opts into immediate tracking, including draft/Sent preview fetches. +Use a stable --operation for retries; uncertain Gmail writes are never retried blindly. + +Environment: + REDPIXELS_URL HTTPS origin of your tracking Worker + REDPIXELS_API_TOKEN Worker token, or use REDPIXELS_SECRETS_FILE + REDPIXELS_SECRETS_FILE Protected JSON with API_TOKEN (default .env.redpixels.json) + REDPIXELS_GMAIL_HOME Private local state directory (default OS user-data directory) + REDPIXELS_GMAIL_TOKEN_STORE keychain (default), or file (explicit, unencrypted 0600) + +Setup and manual tests: docs/gmail-cli.md / docs/gmail-cli.ja.md +`; + +interface CliRuntime { + env?: NodeJS.ProcessEnv; + fetcher?: Fetcher; + credentials?: CredentialStore; + stdout?: (text: string) => void; + stderr?: (text: string) => void; +} +async function openBrowser(url: string): Promise { + const command = + process.platform === "darwin" + ? "open" + : process.platform === "linux" + ? "xdg-open" + : null; + if (!command) return; // On Windows, use the displayed link in the system browser. + await new Promise((resolve) => { + const child = spawn(command, [url], { detached: true, stdio: "ignore" }); + child.once("error", () => resolve()); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} +async function inputFile(path: string): Promise { + const info = await stat(path); + if (!info.isFile() || info.size > 2 * 1024 * 1024) + throw new Error("Email input must be a regular file of at most 2 MiB"); + return readFile(path, "utf8"); +} +async function settings(env: NodeJS.ProcessEnv) { + const origin = trackerOrigin(string(env.REDPIXELS_URL, "REDPIXELS_URL")); + let token = env.REDPIXELS_API_TOKEN; + if (!token) { + const data = object( + await readPrivateJson( + resolve(env.REDPIXELS_SECRETS_FILE || ".env.redpixels.json"), + ), + ); + token = string(data.API_TOKEN, "API_TOKEN in secrets JSON"); + } + if (token.length < 32 || /[\x00-\x20\x7f]/.test(token)) + throw new Error( + "REDPIXELS_API_TOKEN must be at least 32 characters without whitespace", + ); + return { origin, token }; +} + +export async function runCli( + argv: string[], + runtime: CliRuntime = {}, +): Promise { + const stdout = + runtime.stdout ?? ((text: string) => process.stdout.write(text)); + const stderr = + runtime.stderr ?? ((text: string) => process.stderr.write(text)); + const env = runtime.env ?? process.env; + const fetcher = runtime.fetcher ?? fetch; + let operation: string | undefined; + try { + if (!argv.length || argv[0] === "--help" || argv[0] === "help") { + stdout(HELP); + return 0; + } + const [command, ...args] = argv; + const action = command === "auth" ? args.shift() : command; + const key = command === "auth" ? `auth ${action}` : command!; + const allowed: Record = { + "auth login": ["client", "no-browser", "port"], + "auth status": [], + "auth logout": ["revoke"], + draft: ["to", "subject", "html", "text", "operation", "armed"], + verify: ["operation", "draft-id"], + status: ["operation"], + arm: ["operation", "confirm-sent"], + }; + if (!(key in allowed)) + throw new Error( + "Unknown command. This CLI creates drafts and has no send command. Run pnpm gmail --help", + ); + let values; + try { + ({ values } = parseArgs({ + args, + allowPositionals: false, + strict: true, + options: { + client: { type: "string" }, + "no-browser": { type: "boolean" }, + port: { type: "string" }, + revoke: { type: "boolean" }, + to: { type: "string" }, + subject: { type: "string" }, + html: { type: "string" }, + text: { type: "string" }, + operation: { type: "string" }, + armed: { type: "boolean" }, + "draft-id": { type: "string" }, + "confirm-sent": { type: "boolean" }, + }, + })); + } catch { + throw new Error("Invalid arguments. Run pnpm gmail --help"); + } + if (Object.keys(values).some((flag) => !allowed[key]!.includes(flag))) + throw new Error( + "Option is not supported for this command. Run pnpm gmail --help", + ); + const home = gmailHome(env); + const backend = env.REDPIXELS_GMAIL_TOKEN_STORE || "keychain"; + if (!["keychain", "file"].includes(backend)) + throw new Error("REDPIXELS_GMAIL_TOKEN_STORE must be keychain or file"); + const credentials = + runtime.credentials ?? + (backend === "file" + ? new FileCredentialStore(home) + : new KeychainCredentialStore(home)); + const operations = new OperationStore(home); + const emit = (value: unknown) => stdout(`${JSON.stringify(value)}\n`); + if (key === "auth login") { + const client = desktopClient( + await readPrivateJson(string(values.client, "--client")), + ); + await privateDirectory(home); + const port = values.port === undefined ? 0 : Number(values.port); + if (!Number.isInteger(port) || port < 0 || port > 65535) + throw new Error("--port must be an integer from 0 to 65535"); + if (backend === "file") + stderr( + "Using an explicit unencrypted token file protected by OS permissions.\n", + ); + const credential = await authorize({ + client, + fetcher, + port, + onAuthorize: async (url) => { + stderr( + `Open this link in your system browser and approve Gmail draft access:\n${url}\n`, + ); + if (!values["no-browser"]) await openBrowser(url); + }, + }); + await credentials.write(credential); + emit({ + connected: true, + account: credential.email, + token_store: backend, + state_directory: home, + scope: credential.scopes, + }); + return 0; + } + if (key === "auth status") { + const credential = await credentials.read(); + emit({ + connected: !!credential, + account: credential?.email ?? null, + token_store: backend, + state_directory: home, + }); + return 0; + } + if (key === "auth logout") { + if (values.revoke) { + const credential = await credentials.read(); + if (credential) { + const response = await fetcher( + "https://oauth2.googleapis.com/revoke", + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + token: credential.refreshToken, + }).toString(), + redirect: "error", + signal: AbortSignal.timeout(30000), + }, + ).catch(() => { + throw new Error( + "Google revocation failed; local credentials retained", + ); + }); + await response.body?.cancel(); + if (!response.ok) + throw new Error( + "Google revocation was not accepted; local credentials retained", + ); + } + } + await credentials.remove(); + emit({ connected: false, revoked: !!values.revoke }); + return 0; + } + operation = + values.operation || (command === "draft" ? randomUUID() : undefined); + if (!operation) throw new Error("Provide --operation"); + if (key === "status") { + const saved = await operations.read(operation); + if (!saved) throw new Error("Unknown operation"); + emit(result(saved)); + return 0; + } + const services: DraftServices = { + fetcher, + credentials, + operations, + tracker: await settings(env), + }; + if (key === "draft") { + emit( + await createTrackedDraft(services, { + operation, + to: string(values.to, "--to"), + subject: string(values.subject, "--subject"), + html: await inputFile(string(values.html, "--html")), + text: values.text ? await inputFile(values.text) : "", + armed: values.armed === true, + }), + ); + } else if (key === "verify") { + emit(await verifyDraft(services, operation, values["draft-id"])); + } else if (key === "arm") { + emit( + await armDraft(services, operation, values["confirm-sent"] === true), + ); + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : "Gmail CLI failed"; + stdout( + `${JSON.stringify({ error: message, ...(operation ? { operation } : {}) })}\n`, + ); + return 1; + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) + process.exitCode = await runCli(process.argv.slice(2)); diff --git a/cli/oauth.mts b/cli/oauth.mts new file mode 100644 index 0000000..c341b4f --- /dev/null +++ b/cli/oauth.mts @@ -0,0 +1,247 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { CodeChallengeMethod, OAuth2Client } from "google-auth-library"; +import { + GMAIL_API, + GMAIL_SCOPE, + email, + object, + requestJson, + string, + type CredentialStore, + type Fetcher, + type GmailCredential, + type OAuthClient, +} from "./contracts.mts"; + +export function desktopClient(value: unknown): OAuthClient { + const data = object(object(value).installed); + const clientId = string(data.client_id, "Desktop OAuth client ID"); + if (!/^[a-zA-Z0-9._-]+\.apps\.googleusercontent\.com$/.test(clientId)) + throw new Error("Use a Google Desktop app OAuth client JSON"); + return { + clientId, + clientSecret: string(data.client_secret, "OAuth client secret"), + }; +} +function scopes(value: unknown): string[] { + const result = string(value, "granted OAuth scopes").split(/\s+/); + if (!result.includes(GMAIL_SCOPE)) + throw new Error( + "Gmail compose permission was not granted; authorize again", + ); + return result; +} +function googleClient( + client: OAuthClient, + fetcher: Fetcher, + redirectUri?: string, +) { + return new OAuth2Client({ + clientId: client.clientId, + clientSecret: client.clientSecret, + redirectUri, + transporterOptions: { + timeout: 30000, + fetchImplementation: (input, init) => + fetcher( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + { + ...init, + redirect: "error", + signal: init?.signal ?? AbortSignal.timeout(30000), + }, + ), + }, + }); +} +export async function accountEmail( + fetcher: Fetcher, + accessToken: string, +): Promise { + const profile = await requestJson( + fetcher, + `${GMAIL_API}/profile`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + "Gmail profile", + ); + return email(profile.emailAddress); +} +export interface LoginOptions { + client: OAuthClient; + fetcher: Fetcher; + onAuthorize: (url: string) => void | Promise; + port?: number; + timeoutMs?: number; +} + +/** Desktop-only PKCE + state flow. No OOB code copy/paste or Gmail device-code flow. */ +export async function authorize( + options: LoginOptions, +): Promise { + const pkce = await new OAuth2Client().generateCodeVerifierAsync(); + const state = randomBytes(32).toString("base64url"); + let redirectUri = ""; + let processing = false; + let resolveCode!: (value: string) => void; + let rejectCode!: (error: Error) => void; + const codePromise = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + // Attach a handler immediately, including while the URL is being opened. + void codePromise.catch(() => {}); + const server = createServer((request, response) => { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Referrer-Policy", "no-referrer"); + const url = new URL(request.url || "/", redirectUri); + if ( + request.method !== "GET" || + url.pathname !== "/oauth/callback" || + request.headers.host !== new URL(redirectUri).host + ) { + response.writeHead(404).end("Not found"); + return; + } + const returnedState = url.searchParams.get("state") || ""; + if ( + url.searchParams.getAll("state").length !== 1 || + !timingSafeEqual( + createHash("sha256").update(returnedState).digest(), + createHash("sha256").update(state).digest(), + ) + ) { + response + .writeHead(400) + .end("Invalid OAuth state; return to the original login link."); + return; + } + if (processing) { + response.writeHead(409).end("Callback already received"); + return; + } + if (url.searchParams.has("error")) { + processing = true; + response + .writeHead(400) + .end("Authorization denied. Return to your terminal."); + rejectCode(new Error("Gmail authorization was denied")); + return; + } + const code = url.searchParams.get("code"); + if ( + !code || + code.length > 4096 || + url.searchParams.getAll("code").length !== 1 + ) { + response.writeHead(400).end("Missing or invalid authorization code"); + return; + } + processing = true; + response.end( + "Authorization received. Return to your terminal to finish setup.", + ); + resolveCode(code); + }); + let timer: ReturnType | undefined; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port ?? 0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Could not bind OAuth callback"); + redirectUri = `http://127.0.0.1:${address.port}/oauth/callback`; + timer = setTimeout( + () => + rejectCode(new Error("Gmail login timed out; run auth login again")), + options.timeoutMs ?? 300000, + ); + const client = googleClient(options.client, options.fetcher, redirectUri); + const url = client.generateAuthUrl({ + scope: GMAIL_SCOPE, + access_type: "offline", + prompt: "consent", + state, + code_challenge: pkce.codeChallenge, + code_challenge_method: CodeChallengeMethod.S256, + }); + await options.onAuthorize(url); + const code = await codePromise; + let token; + try { + ({ tokens: token } = await client.getToken({ + code, + codeVerifier: pkce.codeVerifier, + })); + } catch { + throw new Error( + "Google OAuth exchange failed; check the Desktop client, Gmail API enablement and consent", + ); + } + const accessToken = string(token.access_token, "OAuth access token"); + const granted = scopes(token.scope); + const refreshToken = string( + token.refresh_token, + "refresh token; consent with offline access again", + ); + return { + version: 1, + ...options.client, + email: await accountEmail(options.fetcher, accessToken), + refreshToken, + scopes: granted, + }; + } finally { + clearTimeout(timer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +/** Refresh only in memory. Tokens never enter Worker bindings, URLs, stdout or journals. */ +export async function access( + store: CredentialStore, + fetcher: Fetcher, +): Promise<{ accessToken: string; credential: GmailCredential }> { + const credential = await store.read(); + if (!credential) + throw new Error( + "Gmail is not connected; run pnpm gmail auth login --client PATH", + ); + const client = googleClient(credential, fetcher); + client.setCredentials({ refresh_token: credential.refreshToken }); + let rotatedRefresh = credential.refreshToken; + client.on("tokens", (token) => { + if (token.refresh_token) rotatedRefresh = token.refresh_token; + }); + let result; + try { + ({ credentials: result } = await client.refreshAccessToken()); + } catch { + throw new Error( + "Google OAuth refresh failed; run auth login again if access was revoked or the testing grant expired", + ); + } + if (result.scope !== undefined) scopes(result.scope); + const accessToken = string(result.access_token, "OAuth access token"); + const actualEmail = await accountEmail(fetcher, accessToken); + if (actualEmail.toLowerCase() !== credential.email.toLowerCase()) + throw new Error( + "Authenticated Gmail account changed; authorize the intended account again", + ); + if (rotatedRefresh !== credential.refreshToken) { + credential.refreshToken = rotatedRefresh; + await store.write(credential); + } + return { accessToken, credential }; +} diff --git a/cli/storage.mts b/cli/storage.mts new file mode 100644 index 0000000..727b599 --- /dev/null +++ b/cli/storage.mts @@ -0,0 +1,248 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, rename, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + GMAIL_SCOPE, + email, + object, + operationName, + string, + type CredentialStore, + type DraftOperation, + type GmailCredential, +} from "./contracts.mts"; + +export function gmailHome(env = process.env): string { + if (env.REDPIXELS_GMAIL_HOME) return resolve(env.REDPIXELS_GMAIL_HOME); + const base = + process.platform === "win32" + ? env.LOCALAPPDATA || join(homedir(), "AppData", "Local") + : process.platform === "darwin" + ? join(homedir(), "Library", "Application Support") + : env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + return join(base, "redpixels", "gmail"); +} +function missing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} +export async function privateDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }); + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) + throw new Error("Gmail state directory must be a real directory"); + if (process.platform !== "win32" && info.mode & 0o077) + throw new Error("Gmail state directory must have mode 0700"); + if (process.getuid && info.uid !== process.getuid()) + throw new Error("Gmail state directory must belong to the current OS user"); +} +export async function readPrivateJson(path: string): Promise { + // O_NOFOLLOW prevents accidentally reading through a credential-file symlink. + const handle = await open( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > 1024 * 1024) + throw new Error("Invalid private JSON file"); + if (process.platform !== "win32" && info.mode & 0o077) + throw new Error( + "Private JSON file must have mode 0600; run chmod 600 on it", + ); + if (process.getuid && info.uid !== process.getuid()) + throw new Error("Private JSON file must belong to the current OS user"); + try { + return JSON.parse(await handle.readFile("utf8")) as unknown; + } catch { + throw new Error("Invalid private JSON file"); + } + } finally { + await handle.close(); + } +} +export async function writePrivateJson( + path: string, + value: unknown, +): Promise { + await privateDirectory(dirname(path)); + const temporary = `${path}.${randomUUID()}.tmp`; + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, path); + } finally { + await unlink(temporary).catch(() => {}); + } +} +export function credential(value: unknown): GmailCredential { + const data = object(value); + if ( + data.version !== 1 || + !Array.isArray(data.scopes) || + !data.scopes.every((scope) => typeof scope === "string") || + !data.scopes.includes(GMAIL_SCOPE) + ) + throw new Error("Unsupported Gmail credential; run auth login again"); + return { + version: 1, + email: email(data.email), + clientId: string(data.clientId, "OAuth client ID"), + clientSecret: string(data.clientSecret, "OAuth client secret"), + refreshToken: string(data.refreshToken, "refresh token"), + scopes: data.scopes as string[], + }; +} +export class FileCredentialStore implements CredentialStore { + readonly path: string; + constructor(home: string) { + this.path = join(home, "credentials.json"); + } + async read(): Promise { + try { + return credential(await readPrivateJson(this.path)); + } catch (error) { + if (missing(error)) return null; + throw error; + } + } + async write(value: GmailCredential): Promise { + await writePrivateJson(this.path, value); + } + async remove(): Promise { + await unlink(this.path).catch((error: unknown) => { + if (!missing(error)) throw error; + }); + } +} +export class KeychainCredentialStore implements CredentialStore { + readonly account: string; + constructor(home: string) { + this.account = createHash("sha256").update(resolve(home)).digest("hex"); + } + private async entry() { + try { + const { Entry } = await import("@napi-rs/keyring"); + return new Entry("redpixels.gmail", this.account, { + linux: { store: "secret-service" }, + }); + } catch { + throw new Error( + "OS keychain unavailable; use REDPIXELS_GMAIL_TOKEN_STORE=file explicitly for a protected, unencrypted local file", + ); + } + } + async read(): Promise { + const entry = await this.entry(); + let raw: string | null; + try { + raw = entry.getPassword(); + } catch { + throw new Error( + "Cannot read OS keychain; unlock it or explicitly select the file token store", + ); + } + if (raw === null) return null; + try { + return credential(JSON.parse(raw)); + } catch { + throw new Error( + "Invalid Gmail credential in OS keychain; run auth login again", + ); + } + } + async write(value: GmailCredential): Promise { + const entry = await this.entry(); + try { + entry.setPassword(JSON.stringify(value)); + } catch { + throw new Error( + "Cannot write OS keychain; no plaintext fallback was used", + ); + } + } + async remove(): Promise { + const entry = await this.entry(); + try { + if (entry.getPassword() !== null) entry.deletePassword(); + } catch { + throw new Error("Cannot remove Gmail credential from OS keychain"); + } + } +} +export class OperationStore { + readonly home: string; + constructor(home: string) { + this.home = home; + } + private path(operation: string): string { + operationName(operation); + return join(this.home, "operations", `${operation}.json`); + } + async read(operation: string): Promise { + try { + const data = object(await readPrivateJson(this.path(operation))); + if ( + data.version !== 1 || + data.operation !== operation || + typeof data.fingerprint !== "string" || + typeof data.state !== "string" || + typeof data.trackingId !== "string" || + typeof data.pixelUrl !== "string" || + typeof data.pixelHtml !== "string" || + typeof data.trackerOrigin !== "string" || + typeof data.account !== "string" || + typeof data.armed !== "boolean" || + ![ + "registered", + "creating", + "created", + "verified", + "verification_failed", + "ambiguous", + ].includes(data.state) || + typeof data.createdAt !== "string" || + (data.draftId !== undefined && typeof data.draftId !== "string") || + (data.gmailMessageId !== undefined && + typeof data.gmailMessageId !== "string") || + (data.verifiedAt !== undefined && + typeof data.verifiedAt !== "string") || + (data.state === "verified" && + (!data.draftId || !data.gmailMessageId || !data.verifiedAt)) + ) + throw new Error("Invalid draft operation state"); + return data as unknown as DraftOperation; + } catch (error) { + if (missing(error)) return null; + throw error; + } + } + async write(value: DraftOperation): Promise { + await writePrivateJson(this.path(value.operation), value); + } + async lock(operation: string, run: () => Promise): Promise { + const lock = `${this.path(operation)}.lock`; + await privateDirectory(this.home); + await privateDirectory(dirname(lock)); + let handle; + try { + handle = await open(lock, "wx", 0o600); + } catch { + throw new Error( + `Operation ${operation} is locked; inspect its journal before removing a stale lock`, + ); + } + try { + return await run(); + } finally { + await handle.close(); + await unlink(lock); + } + } +} diff --git a/docs/agent-setup-gmail.ja.md b/docs/agent-setup-gmail.ja.md new file mode 100644 index 0000000..4ef0a24 --- /dev/null +++ b/docs/agent-setup-gmail.ja.md @@ -0,0 +1,200 @@ +# エージェントに任せる Gmail・Google Cloud セットアップ + +[English](agent-setup-gmail.md) | **日本語** · [README](../README.ja.md) · [Gmail CLI](gmail-cli.ja.md) + +セルフホスト版は、自分の Google Cloud プロジェクトと OAuth クライアントを使う方式をサポートします。Google Cloud の設定を自分で調べる代わりに、以下のプロンプトをブラウザーとターミナルを操作できるエージェントへ渡せます。エージェントに任せられる範囲は、その実行環境の機能・アカウントアクセス・承認ルールによります。Google の本人確認や最終的なアクセス許可は本人が行う場合があります。 + +## 最初に渡すプロンプト + +```text +redpixels のセルフホスト版で、私の Gmail に追跡付きの未送信下書きを +作れるところまでセットアップしてください。 +https://github.com/arkorlab/redpixels の README.ja.md、 +docs/agent-setup.ja.md、docs/agent-setup-gmail.ja.md、 +docs/gmail-cli.ja.md と実際の CLI を確認して進めてください。 + +自分の Google Cloud プロジェクトと Desktop OAuth クライアントを使う +方式を選びます。既存の認証・redpixels 設定があれば再利用してください。 +なければ、利用できるブラウザー操作で Google Cloud の設定を進めてください。 +別のアプリの設定は上書きせず、必要なら redpixels 専用プロジェクトを作ってください。 +Gmail API と最小限の gmail.compose だけを有効にし、課金・カード登録・ +無料トライアル・Compute・Pub/Sub・割当増加は追加しないでください。 + +OAuth の JSON とトークンはチャットや Git に出さず、安全なローカル保存先と +OS の認証情報ストアを使ってください。本人確認・Google の規約への同意・ +Gmail のアクセス許可など、本人の操作が必要な場面だけ、対象画面と理由を +示して引き継いでください。未確認アプリの警告を自動で回避しないでください。 + +Cloudflare が未設定なら cf または Wrangler の対応ガイドに従いデプロイし、 +Gmail の許可待ちの間も独立して進められる作業を続けてください。 +認証後、用途を明記したテスト下書きを 1 通作り、保存済み MIME に画像が +残ることを確認してください。宛先の指定がなければ自分宛てにしてください。 +送信は私が Gmail で行うので、自動送信しないでください。 + +今後「追跡付きの下書きを作って」と頼んだときの操作も永続設定に保存し、 +設定場所、確認できたこと、未確認のこと、費用と再認証の条件を報告してください。 +通常の Gmail 画面で一から書いたメールの自動追跡や、送信済みの自動検知が +実装されているとは説明しないでください。 +``` + +すでに Cloudflare をデプロイしている場合は、その Worker URL と既存のシークレットの**保存場所**を追加してください。値そのものをプロンプトに貼る必要はありません。 + +## 手間と費用 + +初回は、Google Cloud のプロジェクト、Gmail API、同意画面、Desktop クライアント、ローカル認証を設定します。ブラウザー操作をエージェントに任せても、本人確認や許可には利用者の操作が残ります。目安として 10〜30 分ほどを見込むのが現実的ですが、これは見積もりで、組織ポリシーや権限不足による待ち時間は含みません。既存設定を再利用できれば短くなります。 + +**2026-10-16 時点**の Google の説明では、Gmail API の標準利用に追加料金はありません。現在の CLI に必要なのは API・OAuth の設定であり、GCP の有料サーバーや課金アカウントではありません。[Gmail API の料金・割当](https://developers.google.com/workspace/gmail/api/reference/quota)、[プロジェクト作成手順の任意の課金設定](https://developers.google.com/workspace/guides/create-project#optional-enable-billing-for-your-cloud-project) + +通常の新規下書き 1 通は、`getProfile` 1 + `drafts.create` 10 + `drafts.get` 20 = **31 quota units**。1 日 100 通なら **3,100 units** で、ログイン時の確認、追加の `verify`、再試行などは別です。quota units はドルではありません。現行の日次課金しきい値はプロジェクトあたり **80,000,000 units** です。Google は 2026 年後半の標準日次しきい値超過の課金を、少なくとも 90 日前に通知するとしています。永久無料とは約束できないため、導入時には公式ページを再確認してください。[割当の表](https://developers.google.com/workspace/gmail/api/reference/quota)、[変更予定](https://developers.google.com/workspace/tools-safety) + +Cloudflare のプラン・使用量、Google Workspace を使う場合の契約、エージェントの利用料、Webhook 受信先の費用は別です。既存の Google アカウントを使えます。redpixels のために Workspace や GCP の無料トライアルを購入・開始する手順ではありません。 + +## 今の機能の範囲 + +この設定でできるのは、**エージェントが CLI でピクセル入り下書きを作り、人が Gmail の Web・スマホアプリから編集・送信すること**です。GCP を設定しても、Gmail の作成画面で一から書いたすべてのメールに画像が自動で入るわけではありません。Chrome 拡張、Gmail UI 内の既読チェック、送信ゲートウェイ、Sent の自動監視は追加しません。 + +現在の CLI は既定で `armed:false` です。人が送信したとの申告後、`arm --confirm-sent` で通知対象にします。毎回の送信報告をなくす仕組みは未実装です。`--armed` で即時追跡を選べますが、下書き・Sent の閲覧や先読みで通知される可能性があります。これを「自動送信検知」と説明しないでください。 + +将来の Managed 版は、運営側が OAuth アプリ・接続サーバーを管理し、利用者の Google Cloud 設定を省く方向です。**現在提供している機能ではありません。** 料金、公開日、審査完了、通常の Gmail 作成メールへの自動挿入を約束するものでもありません。セルフホスト版は自分の認証情報・Worker を管理したい利用者向けに残します。 + +## エージェントの実行手順 + +### 1. 再開可能な設定を確認する + +- 作業場所・現在のバージョン・無関係な変更を確認します。[デプロイ共通手順](agent-setup.ja.md)と、選んだ [cf](agent-setup-cf.ja.md) または [Wrangler](agent-setup-wrangler.ja.md) の手順を使います。 +- `REDPIXELS_URL`、Worker トークンの保存場所、`REDPIXELS_GMAIL_HOME`、`REDPIXELS_GMAIL_TOKEN_STORE`、OAuth JSON の保存場所、以前の操作 ID を確認します。秘密の値を出力したり、コネクターの内部認証情報を取り出したりしません。 +- `pnpm --silent gmail auth status` は**保存済み認証情報の有無**の確認です。Google への通信やトークン更新が成功した証明ではありません。接続済みなら、新しい試験下書き作成で実際の更新と API アクセスを確認します。 +- 利用者がこの方式のセットアップを依頼済みなら、通常の各設定工程について同じ方式を選ぶか何度も尋ねません。アカウントやプロジェクトの所有者が判断できない、別アプリを変更する、権限・規約・環境の承認で本人操作が必要、という具体的な場面では対象と理由を示します。 + +### 2. 自分のプロジェクトと Gmail API を設定する + +1. [Google Cloud Console](https://console.cloud.google.com/) のログイン先を確認します。利用者が所有する redpixels 用の既存プロジェクトがあれば再利用します。別用途の Branding・Audience・OAuth を変更せず、必要なら専用の `redpixels` プロジェクトを作ります。プロジェクト ID は実際に作られた値を使い、コードや公開例に個人設定を埋め込みません。 +2. そのプロジェクトで [Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com) だけを有効にします。サービスアカウント、ドメイン全体の委任、Compute、Pub/Sub、Sent 監視はこの CLI に不要です。 +3. 課金アカウントの紐付け、カード登録、無料トライアル、割当増加は実施しません。組織のプロジェクト作成権限がないなどの問題を、課金設定で解決しようとしないでください。 +4. 本人確認や規約への明示的な同意が必要なら、その Google 画面を本人へ引き継ぎます。ログイン・同意できたことは画面や後続 API で確認し、操作待ちのまま成功扱いしません。 + +### 3. 同意画面と Desktop OAuth クライアントを作る + +1. [Google Auth Platform](https://console.cloud.google.com/auth/overview) の Branding に用途を示す名前(例 `redpixels personal`)と、利用者が管理するサポート・連絡先メールを設定します。別アプリの公開情報や架空のドメイン・プライバシーポリシーを流用しません。 +2. 個人 Gmail などは **External**。Testing を使う場合は、この CLI で下書きを作る Google アカウントを **Test users** に追加します。対象 Workspace 組織内だけの利用なら、その組織に属するプロジェクトで **Internal** を選べます。外部の宛先へメールを送ることと、外部利用者に OAuth を許可することは別です。 +3. Data Access に `https://www.googleapis.com/auth/gmail.compose` を追加します。この制限付きスコープは**下書き管理に加えて送信権限も含みます**。CLI に送信コマンドがないからといって、トークンが下書き限定とは説明しません。受信箱・Sent を読むスコープは追加しません。 +4. [Clients](https://console.cloud.google.com/auth/clients) → Create client → **Desktop app** を作り、JSON をダウンロードします。ローカル CLI が `127.0.0.1` でコールバックを受けるためです。スマホ Gmail から送る場合も Desktop を使います。**Web application** は将来の Managed サーバー向けの別方式で、現 CLI の代わりにはなりません。 +5. JSON は Git・共有・同期フォルダー外の私有ディレクトリへ保存します。macOS / Linux はディレクトリ `0700`、ファイル `0600` にします。Windows は OS の ACL で所有者以外を制限します。クライアントシークレット、トークン、認可コードをログ・チャット・コミット・Worker の環境変数へ出しません。 + +[Desktop OAuth](https://developers.google.com/identity/protocols/oauth2/native-app)、[Gmail スコープ](https://developers.google.com/workspace/gmail/api/auth/scopes)、[個人・組織内利用等の審査の例外](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification#exceptions-verification-requirements)を参照してください。例外は Google や組織の制限を解除する保証ではありません。未確認アプリの警告・ブロックが出た場合は内容と適用条件を説明し、本人や管理者の判断に引き継ぎます。警告の自動回避や、第三者の OAuth クライアント借用はしません。 + +External / Testing のリフレッシュトークンは、この Gmail スコープでは通常 **7 日で失効**します。Production へ変更しても Google の審査済みにはなりません。個人用の例外や組織ポリシーを確認して publishing 設定を選び、長期運用で再認証が必要になる条件を伝えます。[失効条件](https://developers.google.com/identity/protocols/oauth2#expiration) + +### 4. ローカル CLI で認証する + +Node.js 22.18 以降または 24 LTS、pnpm 10 を使います。リポジトリの固定された依存関係をインストールし、JSON の実際の保存パスを指定します。 + +```sh +pnpm install --frozen-lockfile +pnpm gmail auth login --client /absolute/private/path/client_secret.json +pnpm --silent gmail auth status +``` + +Google 公式 `google-auth-library` が PKCE とトークン交換・更新を処理し、CLI が state 検証と loopback 受付を行います。ブラウザーで本人が対象 Gmail と権限を確認・許可できる間は CLI を動かしておきます。期限切れのリンクを使い続けず、新しいログイン処理のリンクを開きます。パスキー・Touch ID・MFA は本人が操作します。 + +既定は macOS Keychain / Windows Credential Manager / Linux Secret Service です。アクセストークンはメモリだけに置き、リフレッシュトークンとクライアント情報を認証情報ストアへ保存します。ストアが利用不可でも勝手に平文へ切り替えません。ヘッドレス環境などで利用者がファイル保存を選ぶ場合だけ、次を使います。 + +```sh +export REDPIXELS_GMAIL_TOKEN_STORE=file +export REDPIXELS_GMAIL_HOME=/absolute/private/path/redpixels-gmail +pnpm gmail auth login --client /absolute/private/path/client_secret.json --no-browser +``` + +**ファイル方式は暗号化されません。** POSIX では状態ディレクトリ `0700`、認証ファイル `0600` に制限します。OS の認証情報ストアを使える端末では既定を維持してください。エージェントにも同じ保存方式と状態ディレクトリを設定します。 + +`--no-browser` は認証リンクを表示するだけで、localhost の到達性を不要にはしません。SSH のリモート CLI なら、ブラウザー側の端末で `ssh -L 8765:127.0.0.1:8765 user@host` を使い、リモート側で次を実行できます。 + +```sh +pnpm gmail auth login \ + --client /absolute/private/path/client_secret.json \ + --port 8765 --no-browser +``` + +Gmail OAuth に Cloudflare CLI の device-code 手順を流用したり、廃止済みの OOB コード貼り付け方式を案内したりしません。 + +### 5. 実際のトークン更新と下書き作成を試す + +Worker 用のトークンは環境のシークレット管理から渡すか、保護された JSON の `API_TOKEN` を参照します。Google の JSON・トークンとは分けます。 + +```sh +export REDPIXELS_URL='https://redpixels..workers.dev' +export REDPIXELS_SECRETS_FILE='/absolute/private/path/.env.redpixels.json' + +pnpm --silent gmail draft \ + --to recipient@example.com \ + --subject 'redpixels setup test' \ + --html examples/email.html \ + --operation setup-test-001 +``` + +`recipient@example.com` は利用者が指定・管理する宛先へ置き換えます。指定がなければ認証済みの自分宛てを使い、他人へ勝手に試験メールを作りません。`setup-test-001` は新しい試験メールごとに未使用の操作 ID に置き換え、そのメールの再試行だけで再利用します。CLI はトークン更新、Gmail プロフィール照合、redpixels 登録、raw MIME の下書き作成、保存済み HTML の画像検証を行います。実行前に Worker URL・アカウント・用途が正しいことを確認します。 + +成功時は `state:"verified"`、`account`、`tracking_id`、`gmail_draft_id`、`gmail_message_id`、`pixel_verified_at` を記録します。`verified` はその時点の保存内容の確認であり、配信や人の開封の確認ではありません。Gmail で編集・保存した後は次を実行します。 + +```sh +pnpm --silent gmail verify --operation setup-test-001 +``` + +人が Gmail で確認・送信したと申告したら、同じ操作を追跡開始します。 + +```sh +pnpm --silent gmail arm --operation setup-test-001 --confirm-sent +``` + +`--confirm-sent` は申告であり、Sent の自動確認ではありません。送信前の取得も `before_armed` として履歴に残り、追跡開始後も Gmail のキャッシュ・本人判定の限界があります。実開封の試験では、別アカウント宛ての新規メールを Web / iOS でそれぞれ用意し、受信者が開く前に追跡開始・基準値記録を行います。[手動受入テスト](gmail-cli.ja.md#手動の受入テスト)を使い、人工的な URL 取得と区別してください。 + +### 6. 次回以降のエージェント操作を保存する + +利用環境の永続指示に、以下と、リポジトリ・Worker URL・シークレット参照・Gmail 状態ディレクトリの**場所**を保存します。値そのものは指示文へ含めません。永続設定ができなければ保存できたと報告せず、再利用できる指示として利用者へ渡します。 + +```text +追跡付き Gmail 下書きを依頼されたら: +1. redpixels の保存済み環境設定と Gmail のアカウントを確認する。 +2. UTF-8 HTML ファイルを用意し、メールごとに一意で再試行時に安定した + --operation を使って pnpm --silent gmail draft を実行する。 +3. state:verified、各 ID、検証時刻を記録し、Gmail で確認できる下書きを案内する。 +4. 編集後は verify する。送信は本人が行う。CLI に送信コマンドはない。 +5. 本人から送信済みの申告が来たら同じ operation の arm --confirm-sent を実行する。 + 申告がなければ送信済みと推測しない。即時 --armed は本人が選んだ場合だけ使う。 +6. 状態不明の作成を別 operation で盲目的にやり直さず、記録と Gmail を確認する。 +7. 履歴は認証付き API で読み、通知は署名とイベント ID を照合する。 + 画像取得を人が読んだ証拠とは説明しない。 +``` + +## 中断・エラー時の復旧 + +| 状況 | 確認・対応 | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Google Cloud のプロジェクトを作れない | ログイン先、組織・フォルダー、権限・組織の作成制限を確認。本人・管理者に必要な作業だけ引き継ぎ、課金や無関係な組織設定を変えない。 | +| Web client の JSON を渡した | 専用プロジェクトで Desktop app を作成。既存 Web client は削除・変更しない。 | +| `access_denied` / テストユーザー対象外 | OAuth クライアントのプロジェクト、Audience、Test users と実際のログインアカウントを確認。拒否された権限を付与済みと扱わない。 | +| 未確認アプリの警告・管理者ブロック | 内容と個人・組織内の適用条件を示して本人・管理者に引き継ぐ。警告の自動回避や制限解除をしない。 | +| localhost のページが開けない | CLI がまだ待機中か、ブラウザーと CLI の端末が同じか、SSH 転送・固定ポート・有効期限を確認。新しいログインで再試行。 | +| Keychain / Secret Service が使えない | OS のストアのロック・デスクトップセッションを確認。平文ファイル保存は利用者が選んだ場合だけ。 | +| `auth status` は接続済みだが API が失敗 | 保存情報の存在と有効性は別。エラー、許可の取り消し、Testing の 7 日期限、組織ポリシーを調べ、必要なら再ログイン。 | +| Gmail API 未有効 / `insufficient scopes` | 同じプロジェクトの Gmail API と実際の許可を確認。必要な compose の許可で再ログインし、受信箱全体の権限を追加しない。 | +| `ambiguous` / 作成 POST のタイムアウト・5xx | `pnpm --silent gmail status --operation setup-test-001` で状態と ID を保持。Gmail で作成済みか調べ、同じメールを新しい操作 ID で複製しない。 | +| 既存の下書き ID を回収できた | `pnpm --silent gmail verify --operation setup-test-001 --draft-id EXISTING_DRAFT_ID` で同じ画像・アカウントを検証。下書き ID とメッセージ ID は別。 | +| プロセス終了後に `.lock` が残った | 実行中の操作がないこと、操作 JSON と Gmail 側を確認して、その操作のロックだけを除去。操作記録を消さない。 | +| `verification_failed` / 画像がない | 下書き・追跡 ID を残し、保存済み MIME と編集内容を確認。送信や成功の扱いをせず、原因修正後に同じ操作を検証。 | +| 送信後、下書きを読めない | 送信・削除後に下書きがなくなるのは正常。404 だけで送信済みと判断しない。現在の CLI は Sent を読まない。 | +| 開いたのに履歴が増えない | 画像の保持・表示・プロキシ・キャッシュを別々に調べる。再開封を毎回記録できると約束しない。新規ピクセルで再試験する。 | + +同じ論理的なメールは同じ `--operation` を使います。登録の Idempotency-Key とローカルの操作ロックは、それぞれの処理の重複を抑えるもので、Gmail の不明な POST を安全に再送できる保証ではありません。本文・宛先・アカウント等が変わった場合は別のメールとして扱います。 + +## 完了報告 + +秘密を含めず、次を短く報告します。 + +- 利用するアカウント、専用 GCP プロジェクト、Audience / publishing 状態、Desktop client の設定有無。公開コミットへ個人設定は追加しない。 +- Worker URL、cf / Wrangler の選択、認証 JSON・Worker シークレット・状態ディレクトリ・永続指示の保存場所、認証ストアの方式。 +- 保存情報の存在、実際のトークン更新、下書きの `verified`、手動送信、受信 MIME、Web / iOS の新規履歴、署名付き Webhook をそれぞれ確認済み・未確認・阻害要因ありで分ける。 +- テスト操作 ID・下書き ID・追跡 ID・検証時刻、Webhook の有効・無効、再認証の条件、費用の前提。 +- 通常の Gmail 新規作成の自動追跡・自動送信検知は未実装であることと、手動 `arm` または明示的な即時追跡の運用。 + +デプロイや保存済み認証だけで Gmail 連携全体を完了扱いせず、アクセス待ちでも独立して確認できた工程を残してください。[既存 Gmail 接続](gmail-connectors.ja.md)は、対応する MIME 入出力が実際に検証できた場合の任意の短縮経路です。現在の直接 CLI の設定を置き換える前に、その経路自身の検証範囲を確認します。 diff --git a/docs/agent-setup-gmail.md b/docs/agent-setup-gmail.md new file mode 100644 index 0000000..bfd5049 --- /dev/null +++ b/docs/agent-setup-gmail.md @@ -0,0 +1,166 @@ +# Set up self-hosted Gmail drafting with your agent + +**English** | [日本語](agent-setup-gmail.ja.md) · [Gmail CLI](gmail-cli.md) · [Agent setup](agent-setup.md) + +Give an agent this guide together with the request below. It covers the supported bring-your-own-client Gmail integration for self-hosted redpixels. Follow the user's actual authorization and the agent host's access rules; reading this document does not authorize account access, accepting terms, granting permissions, or sending email. + +```text +Set up redpixels' local Gmail draft CLI using docs/agent-setup-gmail.md. +Use my own Google Cloud project and Desktop OAuth client. Reuse verified +existing configuration where suitable; otherwise use a dedicated redpixels +project. Enable only the Gmail API. Do not link billing, add a payment card, +enable paid products, or request a quota increase. +Keep credentials outside Git and chat, with the OS keychain as the token +store. Guide me through login, terms, or Gmail consent when I need to act. +Create one clearly labeled test draft, verify its saved pixel, and leave +the actual Send action to me. Explain the manual post-send arming step and +the limits of owner-open detection. Report what you verified and what is +still pending. Do not promise automatic tracking of ordinary Gmail compose. +``` + +For full deployment, also follow the [common setup guide](agent-setup.md) and the user's choice of [cf](agent-setup-cf.md) or [Wrangler](agent-setup-wrangler.md). Gmail OAuth is separate from Cloudflare authentication. + +## What this setup provides + +- An agent can create a tracked Gmail draft with one CLI command, using Gmail's raw MIME API rather than an HTML-sanitizing connector. +- Each saved draft is read back to check that its image exists. A human then reviews, optionally edits, and sends in Gmail Web or mobile. +- Google refresh tokens remain on the user's machine. The tracking Worker receives no Gmail credentials. + +**It does not track every email composed directly in Gmail.** Such a message has no redpixels image unless an integration inserts one. The current CLI also has no automatic send detection: its default unarmed draft needs `arm --confirm-sent` after the user sends. Creating with `--armed` avoids that later step but allows draft/Sent previews to trigger notifications. Google Cloud setup does not solve either limitation. + +Users who want to avoid managing Google Cloud can choose a compatible [existing Gmail connection](gmail-connectors.md). A future Managed edition could supply an operator-owned Web OAuth application and a Connect Google flow; it is **not shipped by this OSS setup**, and a shared OAuth application still has Google's review and policy requirements. It would not, by itself, insert pixels into ordinary Gmail composition or make proxy requests identify the sender. + +## 1. Inspect before creating anything + +1. Read the current CLI guide and run `pnpm --silent gmail --help`. Use Node.js 22.18+ or 24 LTS and pnpm 10, with the repository's pinned dependencies. +2. Identify the intended sending Google account, local runtime machine, and existing redpixels Worker. Ask only for missing choices; do not guess between unrelated accounts or projects. +3. Use the existing `REDPIXELS_GMAIL_HOME` and `REDPIXELS_GMAIL_TOKEN_STORE` settings, if any. Run `pnpm --silent gmail auth status` without printing environment variables or credential files. Its `connected` field means a saved credential exists, **not that Google has accepted a refresh today**. +4. If credentials already exist for the intended account, do not replace them just to repeat setup. The controlled draft below performs a real token refresh and Gmail profile check. If it fails, diagnose the returned safe error before asking for login again. +5. Inspect the signed-in Google Cloud identity and selected project through the authorized browser or documented API. Prefer an existing dedicated redpixels project the user owns. Otherwise create a dedicated project with no billing link. Do not repurpose another application's shared OAuth Branding/Audience or change an organization's security policies. +6. Reuse a Desktop client only after verifying its ownership, project, audience, and suitability. Do not extract a hosted connector's private tokens or client credentials. A connector's authorization does not log the local CLI in. + +Do not output secrets through `cat`, a shell environment dump, debug HTTP logs, screenshots of credential dialogs, or chat. Report paths, configured account names, scopes, and client type instead. Inspect Git ignore rules before saving configuration. Do not add real Google project identifiers or downloaded credentials to examples, code, issues, or commits. + +## 2. Configure a dedicated Google Cloud project + +Keep the selected project visible through each step so settings do not land in the wrong project. + +1. In [Google Cloud Console](https://console.cloud.google.com/), select the verified project or create a dedicated redpixels project. +2. Enable **Gmail API** in [API Library](https://console.cloud.google.com/apis/library/gmail.googleapis.com). This CLI does not use Cloud Run, Cloud Functions, App Engine, Secret Manager, Cloud Storage, Pub/Sub, a service account, or domain-wide delegation. Do not enable them for this procedure. +3. Open [Google Auth Platform](https://console.cloud.google.com/auth/overview). Configure **Branding** with the app name and the user's appropriate support/contact email addresses. If Google presents terms requiring the user's agreement, show the exact requirement and let the user review and act. Do not accept unseen terms on the user's behalf. +4. Choose **Audience** based on the intended accounts: + - **Internal** is for a Workspace organization's own accounts, when the organization offers it. It does not let unrelated personal Gmail accounts authorize the application; it does not restrict the normal recipients of the sender's email. + - **External** is the applicable choice for a personal Gmail account or accounts outside that organization. While in **Testing**, add the sending Google account to Test users. +5. Under **Data Access**, request only `https://www.googleapis.com/auth/gmail.compose`. This restricted scope permits draft management **and sending**, even though redpixels has no send command. Do not describe it as draft-only permission or add broad inbox access to work around a setup problem. `users.getProfile` accepts this scope and establishes the sender address without adding identity scopes. +6. In [Clients](https://console.cloud.google.com/auth/clients), create a **Desktop app** client. This is correct for a local CLI even when the user later sends the draft from Gmail Web or a phone. A **Web application** client is for a server-hosted callback and is not accepted by this CLI. +7. Download the client JSON to a private local directory outside the checkout and shared/synced folders. On macOS/Linux use `chmod 600 /absolute/private/path/client_secret.json`. The CLI also checks private file ownership and rejects symlinked files. On Windows, use appropriate ACLs; prefer the default credential store for tokens. + +Do not link a billing account, add a payment card, request higher quotas, activate a free trial, or provision paid services. If a page unexpectedly makes one of these a prerequisite, stop that step, report the exact requirement, and inspect whether the correct product/project was selected. Continue independent Cloudflare or documentation work where possible. + +Google's documentation covers [Desktop OAuth](https://developers.google.com/identity/protocols/oauth2/native-app), [Gmail scopes](https://developers.google.com/workspace/gmail/api/auth/scopes), and [personal/internal verification exceptions](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification#exceptions-verification-requirements). Personal use can qualify for an exception, but that does not guarantee a warning-free consent screen or override Workspace admin policy. Let the user review an unverified-app warning and Google's choices; do not bypass it automatically or claim verification. + +For External apps in Testing, refresh tokens normally [expire after seven days](https://developers.google.com/identity/protocols/oauth2#expiration). Explain that before calling setup persistent. Publishing to Production does not itself complete verification. Do not change publishing status or broaden the app's audience just to hide a warning; choose settings appropriate to the user's actual use and Google's requirements. A public Managed application has separate obligations. + +## 3. Authorize the local CLI + +The CLI uses Google's official `google-auth-library` with PKCE, OAuth state validation, and a loopback callback. It is not a hosted application login system. Run on the machine whose private credential store should retain the grant: + +```sh +pnpm gmail auth login --client /absolute/private/path/client_secret.json +pnpm --silent gmail auth status +``` + +Keep the login process alive while the browser flow runs. Use the intended sending account. The user handles passwords, passkeys, MFA, organization prompts, terms, and the final Gmail permission grant. After completion, verify the JSON account and token-store names without exposing token values. Do not declare success because a browser says consent was granted if the CLI timed out or failed to save the credential. + +The default OS backend is macOS Keychain, Windows Credential Manager, or Linux Secret Service, service `redpixels.gmail`. Access tokens stay in memory; the refresh token and client information are stored in the OS backend. Keep the original client JSON private as well. A locked/unavailable keychain produces an error rather than silently writing plaintext. + +If there is no usable OS backend, explain the tradeoff and let the user explicitly select the protected **unencrypted** file backend: + +```sh +export REDPIXELS_GMAIL_TOKEN_STORE=file +export REDPIXELS_GMAIL_HOME=/absolute/private/path/redpixels-gmail +pnpm gmail auth login --client /absolute/private/path/client_secret.json --no-browser +``` + +The POSIX state directory is mode 0700 and `credentials.json` is mode 0600. Persist the selected backend/home in the agent runtime, outside committed files. Do not switch backends on retry without noticing which one holds the credential. + +The callback is `127.0.0.1` on the machine running the CLI. `--no-browser` prints the authorization link; it does **not** move the callback to a cloud URL. For SSH, set up a loopback port forward, such as `ssh -L 8765:127.0.0.1:8765 user@host`, and run remotely with `--port 8765 --no-browser`. The browser's local machine and remote listener must be connected by that forward. Do not use deprecated OOB code-pasting or invent a device-code flow for Gmail scopes. If a callback expired, start a fresh login rather than reuse the stale URL. + +## 4. Create and verify one controlled draft + +Use the existing Worker's protected secret configuration. Keep Google credentials separate: + +```sh +export REDPIXELS_URL='https://redpixels..workers.dev' +export REDPIXELS_SECRETS_FILE='/absolute/private/path/.env.redpixels.json' + +pnpm --silent gmail draft \ + --to recipient@example.com \ + --subject 'redpixels setup test' \ + --html /absolute/path/test-email.html \ + --operation setup-test-001 +``` + +Use a recipient the user authorized. For the initial self-test, the sender's own account is acceptable; use a different account the tester can operate for the controlled recipient-open trial. The command accepts one bare address. Do not send to arbitrary real recipients or run the same test repeatedly. + +Let the CLI perform registration, pixel insertion, raw-MIME draft creation, and saved HTML verification. Do not register another pixel separately. A successful result must contain `state:"verified"`, `tracking_id`, `gmail_draft_id`, `gmail_message_id`, `pixel_verified_at`, and the intended account. The CLI refreshes Google access and checks the authenticated profile, so this is also a live authorization check. Do not fetch the pixel to verify that it is in the draft; that would create a synthetic open. + +Keep `--operation` stable for retries of the same email and reuse exactly the same input. The local journal is needed for recovery. A timeout or 5xx may mean Gmail created a draft even though the response was lost. Do not repeat an ambiguous creation or delete the journal to force it: + +```sh +pnpm --silent gmail status --operation setup-test-001 +# Only after finding the actual existing draft in Gmail/API: +pnpm --silent gmail verify --operation setup-test-001 --draft-id EXISTING_DRAFT_ID +``` + +`status` reports local saved state; it does not recheck Google or delivery. If no stored source can be verified, report that gap instead of claiming the pixel arrived. + +## 5. Human Send, arming, and recipient trial + +1. Show the draft subject, recipient, and Gmail draft identifier. The user reviews and presses Send. The CLI deliberately has no send command. If the user edits first, ask them to save/close and run `pnpm --silent gmail verify --operation setup-test-001` before sending. +2. The default is unarmed, which suppresses notifications from pre-arm fetches. After the user confirms Send, run: + + ```sh + pnpm --silent gmail arm --operation setup-test-001 --confirm-sent + ``` + + This flag records a human assertion; the CLI does not inspect Sent mail. If no send confirmation is available, leave the message unarmed and report the pending step. Do not silently install a watcher or scheduled job. + +3. Capture history counts and webhook baseline before the recipient opens the email. Where authorized access permits, inspect received original MIME; otherwise record that recipient-MIME preservation is unverified. +4. Have the receiving account open the email once, then compare event IDs, timestamps, flags, eligible count, and signed webhook acceptance. An image request is not proof a human read the text. +5. For Web and iOS testing, use a **fresh message, operation, and pixel for each app**. Follow the [manual acceptance test](gmail-cli.md#manual-acceptance-test), record app/OS versions where available, and distinguish user-reported device attribution from proxy evidence. + +Arming does not erase history: pre-arm fetches remain as `before_armed` and count in `open_count`, but not `eligible_open_count`. They may also fill Gmail's image cache. A recipient fetch before arming can be missed as an eligible open, and a sender's Sent view after arming can still trigger one. `--armed` at draft creation removes manual arming **by accepting immediate tracking and those owner-preview risks**; offer it as an explicit tradeoff, never as reliable sender exclusion. No setting guarantees a fresh proxy fetch on every open. + +## Costs and prerequisites to explain + +Google Cloud is used to configure API access, not to host this CLI or the Worker. This setup requires no Google billing link or card, and it does not provision GCP compute, Pub/Sub, or storage. Cloudflare limits and any paid plan are a separate decision. + +As of **2026-10-16**, [Google's Gmail API quota page](https://developers.google.com/workspace/gmail/api/reference/quota) says standard use remains available at no additional cost and announces over-quota charges planned for later in 2026 with at least 90 days' notice. Full billing details are not yet published there. Do not promise Gmail API access is free forever or that unrelated Google Cloud services are free. Recheck the current page if this guide is older than the setup date. + +At current method rates, one successful new draft uses 31 Gmail API quota units (`getProfile` 1 + `drafts.create` 10 + `drafts.get` 20), excluding retries and extra verification. For example, 100 such drafts use 3,100 units. The quota page currently lists an 80,000,000-unit daily billing threshold; quota units are not messages and are not a charge estimate. Ordinary personal drafting is far below that threshold, but this is not a guarantee against costs from other enabled products or future pricing changes. + +Most work is one-time browser configuration: project, API, app/audience, scope, client download, and consent. An authorized agent can do the configuration and local setup; the user's authentication, policy decisions, and permission approval remain personal steps. Workspace admin restrictions, Testing expiry, or a headless callback can make setup longer. Do not promise a fixed completion time. + +## Common problems + +| Symptom | Check and action | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Client JSON is rejected | Confirm a **Desktop app** client and its `installed` JSON were downloaded. Do not edit a Web client JSON to disguise its type. | +| Personal Gmail account is blocked by an Internal app | Internal only covers its Workspace organization. Use the appropriate External audience in a dedicated project; do not modify another app's shared audience. | +| Test user cannot authorize | Check the sending address is listed for External / Testing, the correct account was selected, and organization policy permits the scope. Do not add unrelated users or bypass warnings automatically. | +| Login works, then refresh fails a week later | Check External / Testing expiry and Google's revocation/publishing requirements. Reauthorize only when needed; Production is not the same as verified. | +| Browser reaches an invalid or unreachable localhost callback | Check the original process is alive, has not timed out, and the browser can reach that machine or its SSH forward. Begin a new flow if the old one expired. | +| Keychain is locked or missing | Unlock/configure the user's OS store. Use the unencrypted file backend only as an explicit fallback; do not silently persist tokens elsewhere. | +| `auth status` says connected but drafting fails | Status only reads saved credentials. Inspect the safe error from the real refresh/profile/draft operation; check account, grant, scope, and project API enablement. | +| Connector-created mail loses the image | Use this raw-MIME CLI or a verified MIME-capable connector. `htmlBody` success is not evidence the image survived. | +| Draft creation timed out or returned 5xx | Treat it as ambiguous; inspect Gmail and the operation journal, then recover the actual draft with `verify --draft-id`. Do not blindly POST again. | +| Ordinary Gmail compose is untracked | Expected: nothing inserted a pixel. GCP/OAuth setup alone does not modify Gmail composition. | +| Recipient opening produces no new request | Check arming, prior fetches, cache/proxy behavior, and stored MIME. Do not call the message unread based on absent requests. | +| Console asks for billing or a paid product | Stop that change and check the selected product/project. Billing and additional GCP infrastructure are outside this setup. | + +## Report the result + +Return the sender account, local credential-store type, private client file location, Worker URL, persistent agent workflow location, test operation/draft/tracking IDs, and observed test results. Never include client secrets or tokens. List each stage as verified, pending, or unavailable: saved credentials, successful live refresh/profile, saved image, human Send, manual arming, recipient fetch, and signed webhook receipt. + +Explicitly report remaining UX limits: agent/integration-created drafts rather than automatic native Gmail composition; manual post-send arming by default; proxy caching and imperfect sender exclusion. Keep future Managed ideas separate from shipped features. See the [CLI guide](gmail-cli.md) for logout/revocation and the current live-test evidence. diff --git a/docs/agent-setup.ja.md b/docs/agent-setup.ja.md index 61ccd0f..e457c1b 100644 --- a/docs/agent-setup.ja.md +++ b/docs/agent-setup.ja.md @@ -6,7 +6,9 @@ ユーザーのプロンプトで選んだ [cf の手順](agent-setup-cf.ja.md)または [Wrangler の手順](agent-setup-wrangler.ja.md)を使います。両方の設定を同梱しています。希望のない新規導入には cf(現在ベータ)を案内し、既存の導入ではユーザーが切り替えを選ばない限り CLI を維持します。無断で CLI を変更したり、移行処理を実行したりしません。 -README のコピペ用プロンプトは、認証済み Gmail ユーザー自身への、用途を明記したテストメール 1 通の送信を許可します。それ以外の送信は、既存の下書き確認・送信ルールに従います。 +README のプロンプトは、用途を明記した自分宛ての確認用下書き 1 通を依頼します。人が Gmail で確認して Send を押します。同梱の Gmail CLI に送信コマンドはありません。 + +セルフホストの Gmail 連携は、[自分の Google Cloud OAuth クライアントを使う CLI の設定手順](agent-setup-gmail.ja.md)を標準にします。初期プロンプト、本人への引き継ぎ、料金、復旧、次回以降の操作も同ガイドにあります。利用者がこのセットアップを依頼済みなら、各工程で同じ方式の選択を繰り返し求めず、既存の認証・設定を確認して進めます。[既存の Gmail 接続](gmail-connectors.ja.md)は、利用可能な MIME 経路を実際に確認できる場合の任意の短縮経路です。 ## セットアップ完了に必要なもの @@ -17,13 +19,15 @@ README のコピペ用プロンプトは、認証済み Gmail ユーザー自身 redpixels は API を使うツールです。デプロイだけで Gmail の動作が変わったり、ピクセルのない通常メールを自動追跡したりはしません。Gmail 認証は下書き作成エージェントや手元のクライアントで扱い、Worker には渡しません。 +Google Cloud の設定を完了しても、Gmail で一から作成したメールへの自動挿入や、Sent の自動検知は追加されません。現在の既定の手順には、送信したとの申告後の手動 `arm` が必要です。この未実装部分を設定だけで解決したと報告しないでください。将来の Managed 版は運営側が OAuth を管理する方向ですが、現在提供している機能ではありません。 + ## 1. 環境とデプロイ先を確認する 1. 最新の [README](../README.ja.md)、`package.json`、選んだ CLI の設定を読みます。必要なら `https://github.com/arkorlab/redpixels.git` を clone し、既存の作業場所や無関係な変更は保持します。 2. 必要な Node.js・pnpm のバージョンを確認し、`pnpm install --frozen-lockfile` を実行します。プロジェクトに固定された CLI を使います。cf は Node.js 22.18 以降が必要で、Bun には対応しません。 3. 選んだ CLI の既存の認証を使います。cf と Wrangler の OAuth ログインは別管理です。認証したユーザーと対象アカウントを確認し、複数ある場合は選択を求めます。同名の `redpixels` Worker が存在する場合は内容を確認し、別用途の Worker との名前の衝突はユーザーと解決します。 4. 認証がない場合は選んだ CLI の公式の手順を使います。両方のガイドに localhost のコールバックが使えない場合のデバイス認証を記載しています。ユーザーが許可する間はログイン処理を動かしたままにします。Worker のデプロイに必要なアクセスを使い、実行環境の承認ルールに従います。 -5. 既存の Gmail ツールや API 認証で HTML 下書きを作れるか確認します。未接続なら、そのエージェント環境がサポートする Gmail 接続手順を使い、必要なログイン・同意をユーザーに行ってもらってから組み込みを続けます。直接 API を使う場合、下書きの管理・送信に使う `gmail.compose` は [Google の権限ガイド](https://developers.google.com/workspace/gmail/api/auth/scopes)に記載されています。トークンやパスワードをチャットへ貼らせないでください。対応する接続手段がない場合や必要なアクセスを得られない場合は、独立して進められるデプロイを完了し、Gmail 連携は未完了と明示します。 +5. [Gmail・Google Cloud セットアップ](agent-setup-gmail.ja.md)に従い、既存の認証情報・専用プロジェクトがあれば再利用します。なければ専用プロジェクトで Gmail API・最小の `gmail.compose`・Desktop OAuth を設定し、保護した JSON と OS の認証情報ストアを使います。課金・カード・無料トライアル・追加サービスを設定しません。本人確認・規約・アクセス許可・環境の承認で本人操作が必要なときだけ具体的に引き継ぎます。独立して進められるデプロイは続けます。既存の Gmail 接続を使う場合は [MIME の互換性](gmail-connectors.ja.md)を確認し、内部トークンは取り出しません。秘密の値をチャットへ貼らせないでください。 ## 2. デプロイして設定を保存する @@ -48,26 +52,31 @@ pnpm run deploy:wrangler --secrets-file .env.redpixels.json ## 3. エージェントの Gmail 下書き作成フローに接続する -既存の HTML 対応 Gmail ツールを優先します。直接 API を使う場合は [Google の MIME 下書き手順](https://developers.google.com/workspace/gmail/api/guides/drafts)と [Node サンプル](../examples/gmail-draft.mts)を使えます。初回確認用の HTML 本文は [examples/email.html](../examples/email.html) にあります。 +[Gmail CLI](gmail-cli.ja.md)を使い、[詳しい初期設定](agent-setup-gmail.ja.md)から実際のトークン更新・下書き保存・MIME 検証まで進めます。`auth status` の接続済み表示は保存情報の有無であり、現在のトークンの有効性の証明ではありません。既存コネクターを使う場合も、画像を除去する入力欄へ同じ HTML を繰り返し渡さず、その経路の保存済み MIME を確認してください。[低水準サンプル](../examples/gmail-draft.mts)は認証や更新を自分で管理する場合向けです。 -ユーザーが使っている下書き作成フローや、エージェントがサポートする指示ファイルへ、以下の動作を永続的に設定します。鍵の値ではなく、シークレットの参照を使ってください。 +次の動作をユーザーの下書き作成フローや永続指示へ保存し、鍵の値は参照で扱います。 ```text -HTML Gmail 下書きを作成するとき: -1. 設定されたシークレット管理から REDPIXELS_URL と REDPIXELS_API_TOKEN を読む。 -2. 宛先と件名を POST /v1/messages で登録する。論理的なメールごとに新しい - Idempotency-Key を使い、同じ本文の再試行だけでキーを再利用する。 -3. 返された pixel_html を、HTML 本文内の外部画像として埋め込む。 -4. 下書きの更新時もその画像を保持する。redpixels のメッセージ ID と - Gmail 下書き ID の対応を保持し、ユーザーへ返す。 -5. ユーザーが既に指定した下書き確認・送信のルールに従う。 +追跡付き Gmail 下書きを作るとき: +1. 保存済みの redpixels 環境設定・Gmail アカウント・状態ディレクトリを使う。 +2. UTF-8 HTML と論理的なメールごとに安定した --operation を用意する。 +3. pnpm --silent gmail draft --to ADDRESS --subject SUBJECT --html FILE + --operation OPERATION を実行する。既定は armed:false。 +4. state:verified と追跡 ID・Gmail 下書き ID・メッセージ ID・検証時刻を保存する。 + 画像を確認できなければ連携未完了とし、送信や追跡成功の扱いをしない。 +5. 編集後は pnpm --silent gmail verify --operation OPERATION で再確認する。 +6. 送信は本人が Gmail で行う。送信済みとの申告後に + pnpm --silent gmail arm --operation OPERATION --confirm-sent を実行する。 +7. 作成結果が不明なら status と Gmail を調べ、同じ操作を盲目的に複製しない。 ``` +任意の既存コネクター経路では、安定した Idempotency-Key と `armed:false` で登録し、返された `pixel_html` を対応する MIME / raw 入力へ挿入します。保存済みの画像と各 ID を確認し、申告後に `POST /v1/messages//arm` を呼びます。この経路には CLI の操作記録はないため、独自に対応と不明状態を保持してください。既存接続を利用できることだけで画像保持や配信を保証しません。 + 受信者を分けて追跡する場合はメールとピクセルも分けます。Gmail の下書き ID は安定していますが、その中のメッセージ ID は下書きを置き換えると変わります。redpixels の登録の重複防止は Gmail の下書き作成や送信を重複防止するものではないため、結果が不明な場合は調べてから再試行します。 設定を保存したファイルや項目の正確な場所を記録してください。環境変数を置くだけでは、今後の下書き作成の動作は変わりません。永続設定ができない環境では上の指示を渡し、**手動での組み込みが必要** と明示します。 -ユーザーが別の運用を選ばない限り、登録直後に追跡する既定の動作を使います。`armed:false` と送信後の追跡開始で下書き通知を抑えられますが、即時の取得を取り逃がす場合があり、プロキシのキャッシュも解消しません。Gmail の送信済み監視や定期実行を勝手に追加しないでください。 +新しい CLI は既定で `armed:false` と送信後の手動追跡開始を使います。従来の HTTP API と低水準サンプルは即時追跡を維持し、CLI では `--armed` で明示的に即時追跡を選べます。開始前の取得も履歴には残ること、送信直後の取得を逃す場合があること、キャッシュを解消できないことを説明します。Gmail の送信済み監視や定期実行を勝手に追加しないでください。 ## 4. 受信先がある場合は Webhook を設定する @@ -81,9 +90,9 @@ HTTPS 受信先は redpixels の JSON と、raw body の HMAC・時刻・イベ 1. `pnpm check`、`pnpm test`、選んだ CLI のビルド dry run(`pnpm run build:cf` または `pnpm run build:wrangler`)を実行します。これらはメールを送らず、Gmail の実環境での互換性を確認するものでもありません。保守時は両方をビルドした後、`pnpm run check:cli-builds` で実行コードとストレージ設定を比較できます。 2. 用途を明示した人工的なテストを登録します。公開ピクセルを API の Authorization ヘッダーや所有者 Cookie なしで取得し、認証付き履歴を確認します。HEAD だけでは記録されません。人の既読と扱わないでください。通知を有効にした場合は通知対象かどうかも確認し、受信側で署名検証と受理を確認します。フィルターで正しく除外される場合もあります。 -3. 組み込んだフローで新しい Gmail 確認用下書きを作り、返された画像が HTML にあることを確認します。README のプロンプトなどでユーザーが許可した場合だけ、自分宛てのテスト 1 通を送ります。未許可なら下書きのまま残します。認証済みの Gmail アドレスを確認し、推測で宛先を決めないでください。送信の受付は受信箱への到達の証明ではありません。 +3. 標準の直接 CLI、または利用者が選んだ既存コネクター経路で、用途を明記した新規の確認用下書きを作り、保存済み raw MIME の画像を確認します。別のテスト宛先が指定されていなければ、認証済みの自分のアドレスを使います。人が Gmail で確認して Send を押し、その申告後に追跡を開始します。CLI ガイドの手動受入試験では別の受信アカウントを推奨します。送信の受付は受信箱への到達の証明ではありません。 4. 取得・通知件数の基準値を記録してから、受信メールをユーザーに開いてもらいます。新しいイベント ID・時刻・回数・フラグ・配信状態を照合し、受信先があれば署名も確認します。既存の権限で受信 MIME を確認できる場合は確認し、できなければ未確認と記録します。 -5. モバイル互換性は[実機の記録](mobile-test.md)の手順を使います。実際に操作した端末・アプリを明記し、人工的な取得や単体テストを実機確認の代わりにしないでください。 +5. [既存コネクターの試験](gmail-connectors.ja.md)は新しい未送信下書きの保存確認までです。各経路の検証範囲を区別してください。新CLIの Web / iOS 受入試験は [CLI ガイドの手動手順](gmail-cli.ja.md#手動の受入テスト)を使います。従来の[実機の記録](mobile-test.md)は別の下書き作成経路を使っており、新CLIの検証を代替しません。モバイル互換性は同記録も参照してください。実際に操作した端末・アプリを明記し、人工的な取得や単体テストを実機確認の代わりにしないでください。 選んだ CLI・バージョン・設定ファイル、Worker の URL とアカウント・名前、シークレットの保存場所、下書き作成の永続設定の保存場所、テストの ID・結果、Webhook の状態、不足するアクセスを短く報告します。秘密の値は含めません。 diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 849bec2..ea1656e 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -6,7 +6,7 @@ This guide is for an agent asked to deploy redpixels and connect an existing Gma Use the CLI selected in the user's prompt: [cf setup](agent-setup-cf.md) or [Wrangler setup](agent-setup-wrangler.md). Both configuration files are included. For a new installation without a preference, offer cf (currently beta); preserve an existing installation's CLI unless the user chooses to switch. Do not silently change CLIs or run a migration. -The copyable prompt in the README authorizes one clearly labeled test email to the authenticated Gmail user's own address. All other sending follows the user's existing review/send workflow. +The default self-hosted Gmail path is the [local raw-MIME CLI](gmail-cli.md), with a Google Cloud Desktop OAuth client owned by the user. Follow the [Gmail agent setup instructions](agent-setup-gmail.md) to carry out the configuration and handle the user's login/consent handoffs. Use a dedicated project when no suitable one exists; do not link billing or enable unrelated Google Cloud products. A compatible existing Gmail connection is an optional shortcut when the user prefers it. The setup includes one clearly labeled self-test draft; a human reviews and presses Send. The CLI has no send command. ## What a working setup includes @@ -23,7 +23,7 @@ redpixels is an API-based tool. Deploying it does not change Gmail or automatica 2. Check the required Node.js and pnpm versions, then run `pnpm install --frozen-lockfile`. Use the project's pinned CLI; cf requires Node.js 22.18+ and does not support Bun. 3. Reuse the selected CLI's existing credentials when suitable. cf and Wrangler do not share OAuth sign-ins. Verify the authenticated identity and target account. Ask for an account choice if several are available. Inspect an existing Worker named `redpixels` before changing it; resolve an unrelated name collision with the user. 4. Follow the selected CLI guide's official authentication flow if access is missing. Both guides cover a device flow when localhost callbacks are unavailable. Keep the active login process running while the user approves. Request access appropriate to Worker deployment, and follow the host's permission requirements. -5. Check whether the agent can create HTML Gmail drafts through its existing Gmail tools or API authorization. If not connected, use your host's supported Gmail connection flow and ask the user to complete its sign-in/consent, then resume integration. For direct API clients, Google's [scope guide](https://developers.google.com/workspace/gmail/api/auth/scopes) documents `gmail.compose` for managing drafts and sending. Never request tokens or passwords in chat. If the environment has no supported connection method or required access remains unavailable, complete independent deployment work and clearly report Gmail integration as pending. +5. Inspect the local Gmail CLI's saved credential state without printing secrets. Follow the [Gmail agent setup instructions](agent-setup-gmail.md) for the user's Google Cloud project, Gmail API, Desktop client, private download, and OS-keychain login. Reuse verified existing configuration and do not change an unrelated application's shared Branding/Audience. Keep deployment work moving while a personal login, terms review, or permission grant awaits the user. If the user prefers an existing Gmail connector, use the [connector guide](gmail-connectors.md) to verify its actual saved raw MIME image; an `htmlBody` field alone does not establish compatibility. Never extract private connector tokens or request tokens/passwords in chat. ## 2. Deploy and store configuration @@ -48,26 +48,41 @@ Verify that `/` identifies redpixels, unauthenticated `/v1/messages` returns `40 ## 3. Connect the agent's Gmail draft workflow -Prefer the agent's existing HTML-capable Gmail tools. With a direct API client, use [Google's MIME draft procedure](https://developers.google.com/workspace/gmail/api/guides/drafts) and the working [Node example](../examples/gmail-draft.mts). A starter HTML body is included at [examples/email.html](../examples/email.html). +Use the [local raw-MIME CLI](gmail-cli.md) as the supported self-hosted path. The [Gmail setup recipe for agents](agent-setup-gmail.md) covers prerequisites, personal versus Workspace audiences, Desktop OAuth, private credentials, retries, current Google costs, and manual acceptance. The CLI performs registration, image insertion, raw-MIME creation, and saved-draft verification; do not register another pixel outside it. -Persist the following behavior in the user's existing drafting workflow or agent-supported instructions, using secret references instead of embedding keys: +Persist this behavior in the user's drafting workflow or agent-supported instructions, with secret references: ```text -When creating an HTML Gmail draft: -1. Read REDPIXELS_URL and REDPIXELS_API_TOKEN from the configured secret store. -2. POST /v1/messages with recipient and subject. Use a new Idempotency-Key - for each logical email, and reuse that key only for retries of the same body. -3. Insert the returned pixel_html into the draft's HTML body as a remote image. -4. Preserve that pixel when updating the draft. Keep a mapping of the redpixels - message ID and Gmail draft ID, and return those IDs to the user. -5. Follow the user's existing draft-review and sending instructions. +When creating a tracked Gmail draft: +1. Use redpixels' local Gmail CLI with its existing private credential + store and configured REDPIXELS_URL/API token secret reference. +2. Save the intended HTML to a UTF-8 file and choose a stable --operation + unique to this logical email. Run: + pnpm --silent gmail draft --to RECIPIENT --subject SUBJECT \ + --html HTML_FILE --operation OPERATION +3. Require state:"verified" and retain the account, tracking_id, + gmail_draft_id, gmail_message_id, pixel_verified_at, and armed result. + Do not fetch the pixel as a preservation check. On an uncertain + creation result, inspect the journal and existing Gmail draft before + retrying; use verify --operation OPERATION --draft-id EXISTING_ID + for a known existing draft rather than creating another blindly. +4. Let the human review and press Send in Gmail. After a saved edit, + run pnpm --silent gmail verify --operation OPERATION before sending. +5. Once the human confirms Send, run: + pnpm --silent gmail arm --operation OPERATION --confirm-sent + The CLI does not inspect Sent mail. If no confirmation is available, + leave it unarmed and report that the arming step is pending. +6. Reuse the operation only for retries of identical input; use a fresh + operation and pixel for a different email or recipient. ``` +A compatible existing Gmail connection is an optional shortcut, not a prerequisite for self-hosting. Follow the [connector guide](gmail-connectors.md) when the user chooses it. Require a raw MIME or MIME byte path and actual saved-source verification. For example, one tested connector accepted `mime_type:"text/html"` with `body.base64_url_content`; this does not establish compatibility for other connectors or `htmlBody` fields. Keep a stable registration idempotency key, preserve the exact image and Gmail identifiers, and fail closed if the saved image cannot be verified. A connector's private credentials must not be extracted for the CLI. The [low-level MIME example](../examples/gmail-draft.mts) remains available for custom integrations; it does not manage refresh tokens or verify saved drafts. + Use separate messages/pixels when per-recipient tracking matters. Gmail draft IDs are stable, but underlying message IDs change when a draft is replaced. redpixels registration idempotency does not make Gmail draft creation or sending idempotent: investigate uncertain outcomes before retrying. Record the exact persistent integration path or setting. Environment variables alone do not modify an agent's future drafting behavior. If this environment cannot persist a workflow, provide the above instructions and clearly report **manual integration required**. -Keep the default immediate tracking behavior unless the user chooses otherwise. Optional `armed:false` followed by post-send arming can suppress draft notifications, but can miss a fast recipient fetch and cannot undo proxy caching. Do not silently add a Gmail Sent-folder watcher or scheduled job. +**Current UX limits remain:** emails composed directly in Gmail are not automatically tracked; the pixel must be inserted by this CLI or another integration. Drafts default to unarmed and need manual post-send arming. The existing HTTP API and low-level example retain immediate tracking by default; CLI `--armed` explicitly chooses immediate tracking, accepting possible draft/Sent owner-preview notifications. Pre-arm fetches remain in history, arming can miss a fast recipient fetch, and it cannot undo proxy caching. Explain these tradeoffs rather than claiming Google Cloud setup solves them. Do not silently add a Gmail Sent-folder watcher or scheduled job. A future Managed connection flow is not part of this shipped setup. ## 4. Configure webhooks when a receiver is provided @@ -81,10 +96,10 @@ If no receiver is available, finish history tracking and explain how to add webh 1. Run `pnpm check`, `pnpm test`, and the selected CLI's build dry run (`pnpm run build:cf` or `pnpm run build:wrangler`). These do not send email and do not establish live Gmail compatibility. Maintainers can build both and run `pnpm run check:cli-builds` to compare the generated runtime and storage configuration. 2. Register a clearly labeled synthetic test. Fetch its public pixel without the API Authorization header or owner cookie, then inspect the authenticated history. HEAD alone does not create an event. Label this a synthetic fetch, not a read. If notifications are enabled, inspect eligibility and confirm signed receiver acceptance; filters can legitimately suppress a test event. -3. Create a fresh, clearly labeled Gmail setup test with the integrated workflow. Confirm its HTML contains the returned image. Send exactly one self-addressed test only when the user authorized it, as in the README prompt; otherwise leave a draft. Verify the authenticated Gmail address rather than guessing it. Sending acceptance is not proof of inbox delivery. +3. Create a fresh, clearly labeled setup draft with the local CLI, or the user's selected compatible connector, and verify its saved raw MIME image. The CLI draft operation performs a live token refresh and profile check; `auth status` alone does not. Use the authenticated address for a self-test draft unless the user specifies another test recipient. Let the human review and press Send, then arm after their confirmation. A separate recipient account is preferable for a controlled recipient-open trial. Sending acceptance is not proof of inbox delivery. 4. Record the fetch/webhook baseline, then ask the user to open the received message. Compare the new event IDs, timestamps, counts, flags, and delivery status. Verify the signature at the receiver when available. Inspect received MIME where existing access permits it, or mark that check unverified. -5. For mobile compatibility, use the [real-device test record](mobile-test.md). State which device/app was actually tested. A synthetic request or unit test is not a mobile test. +5. For Web/iOS acceptance, use fresh messages for each recipient app and follow the [CLI manual test](gmail-cli.md#manual-acceptance-test), or the [connector test steps](gmail-connectors.md) when that shortcut was selected. The connector MIME byte path was verified in an unsent draft on 2026-10-16; the separate CLI live trial and earlier [real-device test record](mobile-test.md) used other drafting paths. Do not present them as proof of this new connector path's sending or mobile behavior. State which path, device, and app were actually tested. A synthetic request or unit test is not a mobile test. -Return a concise status report containing the selected CLI/version/configuration, Worker URL and account/name, secret configuration location, persistent drafting integration location, test message IDs/results, webhook status, and any missing access. Never include secret values. +Return a concise status report containing the selected Cloudflare CLI/version/configuration, Worker URL and account/name, secret configuration location, Gmail sender and credential-store type, persistent drafting integration location, test message IDs/results, webhook status, and any missing access. Never include secret values. Report native Gmail composition and manual arming as current limitations, and distinguish future Managed plans from shipped features. Explain the accuracy limits: draft previews and background requests can notify before recipient viewing; even an empty flags list does not prove reading. Proxies can hide identity and repeat opens. A valid signature authenticates a notification, not a human reader. Report each setup step as verified, pending, or unavailable rather than claiming everything is ready after deployment alone. diff --git a/docs/architecture.md b/docs/architecture.md index 0c091ad..b418804 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,8 @@ flowchart LR - `src/lib.ts`: validation, signatures, owner cookies, evidence flags and relative times. - `src/store.ts`: SQLite storage, atomic counts, cursor histories, transactional outbox and alarm delivery. - `src/types.ts` / `src/storage-types.ts`: public data contracts and SQLite row shapes. `src/env.ts` types the Worker bindings and Durable Object RPC. -- `examples/`: assistant-side Gmail integration, history viewer and receiver verification. Gmail access stays outside the Worker. +- `cli/`: draft-only raw Gmail integration, Google OAuth SDK + loopback PKCE/state, OS credential storage, private operation journals, saved-HTML verification and manual arming. Google credentials stay outside the Worker. +- `examples/`: low-level MIME integration, history viewer and receiver verification. The short-lived-token example remains compatible. One server-selected object, `self-hosted:v1`, keeps messages discoverable without an eventually consistent index and serializes the first-notification decision. It targets personal email volume. Schema creation is idempotent; future schema changes require migrations. Numeric cursors and UUID event IDs avoid timestamp collisions. @@ -28,6 +29,14 @@ The server cannot observe actual reads or Gmail sends. Proxy cache hits never ar ## Future hosted edition +The supported self-hosted Gmail setup is bring-your-own Google Cloud project and Desktop OAuth client; a compatible existing connector is an optional shortcut. The local flow uses Gmail API and OAuth only, with no GCP compute, Pub/Sub or billing setup. Follow the [agent Gmail setup guide](agent-setup-gmail.md) for current costs and OAuth lifecycle details. A managed edition could remove project/client setup for end users, but hosting alone does not implement automatic Gmail compose insertion or Sent detection. Neither feature exists in the current release. + +For already tracked drafts, an automatic Sent detector could use Gmail mailbox history through polling or [watch + Pub/Sub](https://developers.google.com/workspace/gmail/api/guides/push). It would require additional read/metadata authorization, a continuously running watcher, draft-to-sent correlation, synchronization recovery, and a policy for fetches arriving before a delayed send notification. `gmail.compose` alone cannot call `watch`. This would remove the human send report, but would not insert pixels into ordinary Gmail-composed mail or eliminate proxy caching. + +For zero per-message interaction in native Gmail, a Workspace-specific option is an [outbound gateway](https://knowledge.workspace.google.com/admin/gmail/advanced/add-an-outbound-gateway-for-outgoing-email): after Send, an SMTP service could register the message, insert the pixel, arm tracking, and relay to the recipient. This is a proposed architecture, not a verified redpixels implementation. It requires Workspace administrator routing, an SMTP relay with queues/retries, access restrictions and TLS, plus SPF/DKIM handling after modifying the message. The documented route is for Workspace organizations, not a personal `@gmail.com` account. The current Worker cannot serve as the public SMTP endpoint: [Workers TCP support](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/) does not accept public inbound TCP. This optional delivery system is outside the shipped one-Worker setup. + +The local CLI uses a Desktop OAuth client and keeps Google refresh tokens in the owner's OS credential store. A future hosted Gmail integration needs its own Web OAuth client with a server-side HTTPS callback and encrypted, tenant-scoped token storage. Use a separate production Google Cloud project from personal/internal or development clients. Hosted users would authorize the operator's application; they would not each configure a Desktop client. Keep MIME generation and saved-draft verification reusable, while replacing the OAuth adapter, credential store and operation journal. A public hosted integration using `gmail.compose` needs Google's restricted-scope verification and, where applicable, a security assessment; adding a Web client alone does not satisfy those requirements. + Replace authentication with verified key lookup returning a server-controlled principal/tenant, then derive object names from that tenant. Pixel routes will need an opaque tenant locator and explicit isolation tests. Never trust a caller-supplied tenant ID. The current release has one owner only. Registration, observed requests, eligible requests, enqueued notifications and acknowledged delivery have separate records/counters. These can feed later metering after defining billing events and idempotent usage export. `eligible_open_count` is not human readership; `webhook_count` is not billing usage. Add quotas, abuse controls, retention/export policies, per-tenant secrets and schema migrations with hosted requirements. No billing or hosted control plane is included. diff --git a/docs/gmail-cli.ja.md b/docs/gmail-cli.ja.md new file mode 100644 index 0000000..5828635 --- /dev/null +++ b/docs/gmail-cli.ja.md @@ -0,0 +1,145 @@ +# Gmail の raw MIME 下書き CLI + +[English](gmail-cli.md) | **日本語** · [README](../README.ja.md) + +**自分の Google Cloud OAuth クライアントを使う、セルフホスト版の Gmail 連携です。** [エージェント向けの初期プロンプトと詳しい設定手順](agent-setup-gmail.ja.md)を渡せば、プロジェクト作成から実際の下書き検証まで依頼できます。既存の Gmail 接続を利用できる環境では、[対応を確認したコネクター](gmail-connectors.ja.md)も任意の短縮経路です。 + +`pnpm gmail draft` は、メールの登録、ピクセルの挿入、RFC 2822 / MIME の構築、Gmail `users.drafts.create` への `message.raw` の送信、保存内容の読み戻し検証までを 1 コマンドで行います。**下書き専用で、メールは送信しません。** 人が Gmail で確認して Send を押します。 + +一部のコネクターは `htmlBody` の `` を除去します。HTML をコネクターへ渡しただけでは画像保持を確認したことになりません。この CLI は Gmail API に直接アクセスし、保存済みの `text/html` MIME パートで画像を検証します。CLI 自体はピクセルを取得しません。保存後の Gmail 編集・送信時にも画像が保持されるかは別の確認です。 + +GCP を設定しても、**通常の Gmail 作成画面で一から書くすべてのメールへの自動挿入や、送信済みの自動検知はできません**。エージェントが作る追跡付き下書きが対象です。既定の運用では、人が送信したと申告した後に手動で追跡を開始します。この操作を不要にする機能は未実装です。 + +初回の設定は、本人確認・同意画面・クライアント作成が主な手間です。2026-10-16 時点、[Gmail API の標準利用は追加費用なし](https://developers.google.com/workspace/gmail/api/reference/quota)で、現在のローカル CLI のために GCP 課金・カード登録・無料トライアルを有効にする必要はありません。通常の新規下書き作成と確認は約 31 quota units です。将来の標準枠超過の課金予定、Cloudflare などの別費用、設定時間の目安は[導入ガイド](agent-setup-gmail.ja.md#手間と費用)にまとめています。 + +## 設計の選択 + +- **リポジトリ同梱の TypeScript CLI。** 既存の Worker・API・SQL スキーマを変えず、Gmail の認証情報を端末上に置きます。認証サーバー、MCP サーバー、公開 Gmail 送信 API は追加しません。エージェントはコマンドの JSON 出力を利用できます。 +- **Google 公式 `google-auth-library`。** PKCE、認可 URL、トークン交換・更新を公式 SDK に任せます。localhost の受付と state 検証は CLI が行います。Web アプリ用のログイン・セッション DB は不要です。 +- **自分の OAuth クライアント。** 各セルフホスト利用者が Google Cloud の Desktop app クライアントを作ります。共用クライアントやサービスアカウント、ドメイン全体の委任は使いません。 +- **下書き専用。** 要求する `gmail.compose` は Google 側では下書き管理と送信を許可する制限付きスコープです。CLI に送信コマンドがないことと、トークンの権限が送信を含むことは別です。一般の受信箱を読む権限は追加要求しません。 +- **送信後に手動で追跡開始。** 下書きは既定で `armed:false`。送信前の通知を抑え、送信した人が明示的に追跡開始します。送信済みの自動監視は追加しません。 + +デスクトップ型はローカル CLI の認証方式です。作成した下書きを Gmail の Web やスマホから送る場合も変わりません。将来の Managed Web 版では、別の Web OAuth クライアントとサーバー側のトークン保存を使います。Managed 版の利用者は接続ボタンで許可するだけで、自分のクライアント作成は不要にする想定です。この OSS 版には Managed 版の実装は含みません。[設計メモ](architecture.md#future-hosted-edition)と [Google のクライアント種別](https://developers.google.com/workspace/guides/create-credentials)も参照してください。 + +## 1. Google Cloud で OAuth クライアントを作る + +1. [Google Cloud Console](https://console.cloud.google.com/) で自分が管理する redpixels 用のプロジェクトを選ぶか、専用プロジェクトを作ります。別のアプリの Branding・Audience・OAuth 設定は上書きしません。Gmail API 以外の有料サービス・課金・無料トライアル・Pub/Sub・割当増加は追加しません。 +2. そのプロジェクトで [Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com) を有効にします。 +3. [Google Auth Platform](https://console.cloud.google.com/auth/overview) の Branding / Audience を設定します。個人用途のアプリ名、サポート・連絡先メールを設定。External の Testing を使う場合は、下書きを作る自分の Google アカウントを Test users に追加します。Internal は対象 Workspace 組織内だけで使う場合に選びます。 +4. Data Access に `https://www.googleapis.com/auth/gmail.compose` を追加します。 +5. [Clients](https://console.cloud.google.com/auth/clients) → Create client → **Desktop app** を選択し、JSON をダウンロードします。Web application の JSON は使えません。 +6. JSON はリポジトリ・共有・同期フォルダーの外に保存します。macOS / Linux では私有ディレクトリを `0700`、`chmod 600 /absolute/path/client_secret.json` でファイルを所有者だけが読めるようにします。Windows は OS の ACL を使います。値をチャット・Git・Worker の環境変数に入れないでください。 + +Google の [Desktop OAuth 手順](https://developers.google.com/identity/protocols/oauth2/native-app)と [Gmail スコープ](https://developers.google.com/workspace/gmail/api/auth/scopes)も参照してください。`gmail.compose` は制限付きスコープで、組織管理者の許可が必要な場合があります。個人・組織内利用などの[審査の例外](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification#exceptions-verification-requirements)と、不特定多数向けアプリの審査要件は区別してください。redpixels が審査済みの共通 OAuth アプリを提供するわけではありません。本人確認・規約への同意・権限付与は本人が画面を確認し、未確認アプリの警告や組織の制限をエージェントが自動で回避しないでください。 + +External / Testing のリフレッシュトークンは通常 [7 日で失効します](https://developers.google.com/identity/protocols/oauth2#expiration)。長期運用の Audience / publishing 設定は自分の用途と Google の要件に合わせて選びます。Production への変更だけで審査済みにはなりません。取り消し・ポリシー・パスワード変更でも再認証が必要になる場合があります。 + +## 2. 一度ログインする + +Node.js 22.18 以降または 24 LTS、pnpm 10 を使います。 + +```sh +pnpm install --frozen-lockfile +pnpm gmail auth login --client /absolute/path/client_secret.json +pnpm gmail auth status +``` + +ブラウザーで対象の Gmail アカウントを選び、権限を確認します。CLI は Gmail `users.getProfile` で本人のメールアドレスを確認し、そのアドレスを From にします。任意の From や送信エイリアスは今回の CLI では扱いません。ログイン結果・下書き結果は JSON で返り、秘密の値は表示しません。スクリプトで純粋な JSON だけが必要な場合は `pnpm --silent gmail ...` を使えます。 + +`auth status` は保存済みの認証情報があるかを表示するコマンドで、Google との疎通・更新成功を確認しません。下書き作成では実際に更新・プロフィール照合・Gmail API 呼び出しを行います。再開時は既存設定を使い、状態表示だけで接続全体を成功扱いしないでください。 + +既定の保存先は macOS Keychain / Windows Credential Manager / Linux Secret Service。サービス名は `redpixels.gmail` で、ローカル状態ディレクトリから導いたアカウント名を使います。リフレッシュトークンとクライアント情報を保存し、アクセストークンは実行中のメモリだけに置きます。ロック中・利用不可の認証ストアを平文ファイルへ自動で切り替えることはしません。 + +Secret Service のないヘッドレス環境では、以下を**明示的に選択**できます。 + +```sh +export REDPIXELS_GMAIL_TOKEN_STORE=file +export REDPIXELS_GMAIL_HOME=/absolute/private/path/redpixels-gmail +pnpm gmail auth login --client /absolute/path/client_secret.json --no-browser +``` + +ファイル方式は暗号化されません。状態ディレクトリは 0700、`credentials.json` は 0600 に制限します。Windows では Unix のモードだけでは ACL の代わりにならないため、既定の認証ストアを推奨します。状態ディレクトリは Git 同期・共有フォルダーの外に置きます。エージェントの実行環境でも同じ環境設定を保持してください。 + +OAuth のコールバックは `127.0.0.1` の空いているポートで受けます。`--no-browser` はリンクを表示するだけで、localhost の到達性は必要です。SSH 上なら、例として `ssh -L 8765:127.0.0.1:8765 user@host` で転送し、リモートで `--port 8765 --no-browser` を指定します。廃止済みの OOB コード貼り付けや、Gmail スコープに使えない device-code 認証は使いません。 + +## 3. 1 コマンドで下書きを作る + +既存の Worker URL を設定します。Worker のトークンはエージェントのシークレット管理から `REDPIXELS_API_TOKEN` に渡すか、所有者だけが読める JSON の `API_TOKEN` を使います。 + +```sh +export REDPIXELS_URL='https://redpixels..workers.dev' +export REDPIXELS_SECRETS_FILE='/absolute/path/.env.redpixels.json' + +pnpm --silent gmail draft \ + --to recipient@example.com \ + --subject 'Hello' \ + --html examples/email.html \ + --operation hello-001 +``` + +`REDPIXELS_SECRETS_FILE` の既定値は現在のディレクトリの `.env.redpixels.json`。Worker 用の `API_TOKEN` を読み、Google の認証情報はそこへ追加しません。本文は UTF-8 HTML ファイルで、`--text email.txt` を追加すると plain text パートも設定できます。宛先は 1 つの通常のメールアドレスに限定し、宛先リスト・表示名・添付・返信スレッドは今回の CLI の対象外です。HTML・テキストはそれぞれ最大 2 MiB。 + +成功時は `operation`、`account`、`tracking_id`、`gmail_draft_id`、`gmail_message_id`、`state:"verified"`、`pixel_verified_at`、`armed` を返します。保存した MIME に画像がなければ終了コード 1 になります。`pixel_verified_at` は確認した時点であり、その後の編集まで保証しません。編集後は送信前に再確認できます。 + +```sh +pnpm --silent gmail verify --operation hello-001 +``` + +人が Gmail で確認して Send を押したら、追跡を開始します。 + +```sh +pnpm --silent gmail arm --operation hello-001 --confirm-sent +``` + +`--confirm-sent` は人・エージェントからの申告です。CLI が送信済みフォルダーで確認したという意味ではありません。送信直後の取得を逃す場合があるため、受信者のテスト閲覧前に追跡開始します。 + +従来と同じ即時追跡が必要なら、下書き作成時に `--armed` を指定します。この場合は送信前のプレビューでも通知され得ます。 + +## 再試行と復旧 + +論理的なメールごとに異なる `--operation` を使い、同じメールの再試行だけで再利用します。宛先・件名・本文・アカウント・Worker・初期 armed 設定が変われば同じ操作IDでは拒否します。本文そのものやトークンは操作記録へ保存せず、入力のハッシュと下書き・追跡IDなどを保存します。 + +正常に完了した同じ操作は既存の結果を返します。Gmail のタイムアウト・5xxなどでは、作成された可能性があるため自動で POST をやり直しません。 + +```sh +pnpm --silent gmail status --operation hello-001 +# Gmail / Gmail API で既存下書きを調べ、実際の draft ID がわかった場合: +pnpm --silent gmail verify --operation hello-001 --draft-id EXISTING_DRAFT_ID +``` + +下書きIDと、その中のメッセージIDは異なります。送信後に下書きがなくなるのは正常です。この CLI は受信箱や送信済みの MIME を取得しません。処理が強制終了して `.lock` が残った場合は、操作が動いていないことと JSON 記録・Gmail 側を調べてから、その操作のロックだけを除去します。操作記録を消して盲目的に再作成しないでください。 + +```sh +pnpm gmail auth logout +# Google 側の許可も取り消す場合(同プロジェクトの他クライアントにも影響し得る): +pnpm gmail auth logout --revoke +``` + +## 自分の閲覧と Gmail プロキシの限界 + +- 追跡開始前の取得も、既存 API の仕様どおり `before_armed` として保存され、`open_count` に含まれます。通知は発生せず、`eligible_open_count` は増えません。履歴から自分の取得を消したという意味ではありません。 +- Google の画像プロキシは送信者の Cookie・認証ヘッダー・実 IP を渡さないため、Gmail の下書き・Sent の閲覧を確実に本人判定できません。`SELF_IPS` や所有者 Cookie は直接取得の補助に限られます。 +- **未追跡中の画像応答もプロキシにキャッシュされる場合があります。** 送信後に arm しても、キャッシュに残った画像を再取得させることはできません。Sent を開くと、その後も通知され得ます。時刻・UAによる人間判定も保証しません。 +- [Google の説明](https://support.google.com/mail/answer/145919?hl=en)も、画像の自動表示・安全確認・Cookie の制限を記載しています。各 Web / iOS 閲覧や再開封が毎回 origin に届くことは保証できません。 + +## 手動の受入テスト + +Web と iOS は**別々の新しいメール・操作ID・ピクセル**で試します。同じメールを順番に開くとキャッシュにより 2 回目の判定ができない場合があります。 + +1. 上の OAuth 手順を完了し、送信者のアカウントが正しいことを確認します。 +2. 自分が操作できる**別の Gmail アカウント**宛てに `draft` で新規下書きを作ります。JSON の `state:"verified"` と各 ID を保存します。 +3. Gmail で下書きを編集した場合は、閉じて保存し、CLI の `verify` を実行。画像がないエラーなら送信しません。 +4. 人が送信します。既存の閲覧権限があれば送信済み/受信メールの「メッセージのソースを表示」や raw MIME でも元の画像 URL を確認します。下書きの検証だけで受信 MIME を検証したとは報告しません。 +5. `arm --confirm-sent` を実行し、受信メールを開く前の履歴件数・最終イベントを記録します。 +6. 別アカウントの Gmail Web で 1 回開き、履歴APIを再取得して新規イベント・UTC時刻・フラグ・通知の状態を比較します。Webhook は受信先で署名とイベントIDを照合します。 +7. 新しいメールで 2〜6 を繰り返し、今回は受信側の iOS Gmail で開きます。OS・アプリのバージョンを記録します。 +8. 同じメールの再開封は別項目として調べます。記録が増えないことを「未読」や「画像消失」と断定しません。 + +CLI の自動テストは Gmail / redpixels の HTTP 境界を模擬しており、本物の OAuth 同意・Gmail 配信・実機閲覧の確認ではありません。別途 **2026-10-16** に、新 CLI・デプロイ済み Worker・署名検証する Webhook 受信先を使い、以下を確認しました。 + +- Workspace 内部アプリの Desktop OAuth が完了し、macOS キーチェーンに認証情報を保存。別々の CLI プロセスでトークンを更新して raw MIME の下書きを2通作成し、Gmail から保存済み本文を読み戻して両方の画像を確認。履歴は各0件でした。 +- 人が別の Gmail アカウントへ手動送信。編集・送信の操作中に各1件の Google 画像プロキシ取得がありましたが、開始前のため `before_armed` として履歴に残り、通知は出ませんでした。 +- 手動で追跡開始後、利用者が Web 用メールを Gmail Web で、別の iOS 用メールを iOS の Gmail で開いたと報告。各履歴は **1 → 2**、通知対象の回数は各 **1** になり、新しいイベント ID と一致する署名検証済み Webhook を **HTTP 204** で受信しました。記録時刻は Web 用 **11:24:43.849Z**、iOS 用 **11:24:45.841Z** です。 + +端末の区別は利用者の操作報告によります。どちらも Google 画像プロキシ経由のため、リクエスト単体では実際の端末や読了を確定できません。受信側の raw MIME と今回の正確な iOS / Gmail バージョンは取得していません。Windows / Linux の認証情報ストア、繰り返し開封、返信、開始後の自分の閲覧、Apple MPP の実挙動は未検証です。従来の [モバイル保存確認](mobile-test.md)は別の下書き作成経路で行っています。 diff --git a/docs/gmail-cli.md b/docs/gmail-cli.md new file mode 100644 index 0000000..ffd740b --- /dev/null +++ b/docs/gmail-cli.md @@ -0,0 +1,145 @@ +# Gmail raw MIME draft CLI + +**English** | [日本語](gmail-cli.ja.md) · [README](../README.md) + +**Supported self-hosted Gmail integration.** This direct CLI uses your own Google Cloud Desktop OAuth client. Give your agent the [complete Gmail setup instructions](agent-setup-gmail.md) to handle project selection, API enablement, OAuth configuration, local credential storage, and a verified test draft. A compatible [existing Gmail connection](gmail-connectors.md) is an optional shortcut when you prefer to reuse one. + +`pnpm gmail draft` registers a message, inserts its pixel, builds RFC 2822 / MIME, calls Gmail `users.drafts.create` with **`message.raw`**, and reads back the saved HTML MIME part to verify the image—all in one command. **It creates drafts and never sends email.** A human reviews the draft and presses Send in Gmail. + +Some connectors remove `` from an `htmlBody` parameter. Giving HTML to a connector does not establish pixel preservation. This CLI calls Gmail directly and checks the stored draft; it never fetches the pixel itself. Later editing/sending in Gmail is a separate preservation check. + +## Decisions + +- **A TypeScript CLI in this repository.** No changes to the Worker, API or SQL schema. Gmail credentials stay on the user's machine. No public mail endpoint, separate MCP server, authentication server or session database. Agents can consume JSON output. +- **Google's official `google-auth-library`.** It generates PKCE/authorization URLs and handles token exchange/refresh. The CLI implements the loopback listener and state validation. There is no application login/session layer to manage. +- **Bring your own Desktop OAuth client.** Each self-hosting user controls their Google Cloud project. No shared OAuth client, service account or domain-wide delegation. +- **Draft-only behavior.** Google's `gmail.compose` restricted scope also grants send access; the CLI's lack of a send command does not reduce the token's actual permissions. No additional inbox-reading scope is requested. +- **Manual post-send arming.** Drafts default to `armed:false` to suppress draft-time notifications. The user confirms sending before arming. No Sent-folder watcher or scheduled task. + +The Desktop client is for this local CLI, even when the resulting draft is sent from Gmail Web or mobile. A future managed Web integration would use a separate Web OAuth client and server-side token storage. Managed users would click Connect Google rather than create their own client. That hosted integration is outside this OSS release; see the [architecture notes](architecture.md#future-hosted-edition) and [Google's client types](https://developers.google.com/workspace/guides/create-credentials). + +## 1. Create a Google Desktop OAuth client + +An agent with authorized browser access can carry out the configuration below. You still handle account authentication, review Google's terms when requested, and approve Gmail access yourself. The [agent setup guide](agent-setup-gmail.md) covers resuming existing installations and common failures. This setup does not enable automatic tracking of messages composed directly in Gmail: an email needs the pixel inserted through this CLI or another compatible integration. Post-send arming is also still manual unless you deliberately choose `--armed` and accept owner-preview notifications. + +1. Select or create a project you control in [Google Cloud Console](https://console.cloud.google.com/). +2. Enable the [Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com) in that project. +3. Configure Branding / Audience in [Google Auth Platform](https://console.cloud.google.com/auth/overview), including the app's name, support email and contact email. For External / Testing, add the sending Google account to Test users. Choose Internal only for use within your Workspace organization. +4. Add `https://www.googleapis.com/auth/gmail.compose` under Data Access. +5. Open [Clients](https://console.cloud.google.com/auth/clients), create a **Desktop app** client, and download its JSON. A Web application client is not supported here. +6. Store it outside the checkout. On macOS/Linux, run `chmod 600 /absolute/path/client_secret.json`. Never paste its contents in chat, commit it, or upload it to the Worker. + +See [Google's Desktop OAuth procedure](https://developers.google.com/identity/protocols/oauth2/native-app) and [Gmail scopes](https://developers.google.com/workspace/gmail/api/auth/scopes). `gmail.compose` is restricted and may require organization-admin approval. Distinguish Google's [verification exceptions](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification#exceptions-verification-requirements) for personal/internal use from requirements for public apps. redpixels does not supply a shared, verified OAuth application. + +External / Testing refresh tokens usually [expire after seven days](https://developers.google.com/identity/protocols/oauth2#expiration). Choose audience/publishing settings appropriate to your use and Google's requirements; switching to Production does not itself verify an app. Revocation, policy changes or password changes can also require login again. + +### Google Cloud costs + +This path uses a local Node.js process and the Gmail API; it does not deploy Google Cloud compute, storage, Pub/Sub, or a mail server. No billing account or payment card is required by this setup procedure. Do not enable paid services or link billing to get through it. + +As of **2026-10-16**, Google's [Gmail API quota page](https://developers.google.com/workspace/gmail/api/reference/quota) says standard use remains available at no additional cost. It also announces over-quota charges planned for later in 2026, with at least 90 days' notice; full billing details are not yet on that page. Check the current policy before changing usage or enabling billing. A normal successful new CLI draft currently uses `getProfile` (1 unit), `drafts.create` (10), and `drafts.get` (20): **31 Gmail API quota units**, excluding retries and extra verification. Cloudflare hosting limits/costs are separate. + +## 2. Sign in once + +Use Node.js 22.18+ or 24 LTS and pnpm 10. + +```sh +pnpm install --frozen-lockfile +pnpm gmail auth login --client /absolute/path/client_secret.json +pnpm gmail auth status +``` + +Choose the sending account in the browser and review consent. Gmail `users.getProfile` establishes the authenticated address used as From. Arbitrary From addresses/aliases are outside this CLI's scope. Results are JSON and never include secret values; use `pnpm --silent gmail ...` when an agent needs only JSON without pnpm's script banner. + +The default token store is macOS Keychain / Windows Credential Manager / Linux Secret Service, under service `redpixels.gmail`, with an account identifier derived from the state-directory path. It stores the refresh token and client information; access tokens remain in memory. A locked or unavailable keychain never silently falls back to plaintext. + +For headless machines without Secret Service, explicitly opt into a file: + +```sh +export REDPIXELS_GMAIL_TOKEN_STORE=file +export REDPIXELS_GMAIL_HOME=/absolute/private/path/redpixels-gmail +pnpm gmail auth login --client /absolute/path/client_secret.json --no-browser +``` + +The file backend is **unencrypted**, with a 0700 state directory and 0600 `credentials.json`. On Windows, Unix modes are not a substitute for ACLs; prefer the default OS store. Keep state outside Git, shared folders and synced workspaces. Persist the selected environment settings in the agent's runtime too. + +The OAuth callback listens on an available `127.0.0.1` port. `--no-browser` prints a link but still needs loopback reachability. For SSH, forward a port, for example `ssh -L 8765:127.0.0.1:8765 user@host`, and run login remotely with `--port 8765 --no-browser`. Deprecated OOB code-pasting and device-code authentication that cannot grant Gmail scopes are not used. + +## 3. Create a draft in one command + +Configure your existing Worker URL. Supply its token through the agent's secret manager as `REDPIXELS_API_TOKEN`, or use a protected JSON file containing `API_TOKEN`: + +```sh +export REDPIXELS_URL='https://redpixels..workers.dev' +export REDPIXELS_SECRETS_FILE='/absolute/path/.env.redpixels.json' + +pnpm --silent gmail draft \ + --to recipient@example.com \ + --subject 'Hello' \ + --html examples/email.html \ + --operation hello-001 +``` + +`REDPIXELS_SECRETS_FILE` defaults to `.env.redpixels.json` in the current directory. It supplies the Worker's `API_TOKEN`; do not put Google credentials in it. Input is a UTF-8 HTML file. Add `--text email.txt` for the plain-text alternative. This version accepts one bare recipient address, without display names/lists, attachments, or reply threads. HTML and text inputs are each limited to 2 MiB. + +Success returns `operation`, `account`, `tracking_id`, `gmail_draft_id`, `gmail_message_id`, `state:"verified"`, `pixel_verified_at`, and `armed`. Missing images fail with exit code 1. The verification timestamp proves the saved draft at that time, not later edits. After editing, save/close Gmail and verify again before sending: + +```sh +pnpm --silent gmail verify --operation hello-001 +``` + +Review and **press Send in Gmail**, then arm: + +```sh +pnpm --silent gmail arm --operation hello-001 --confirm-sent +``` + +`--confirm-sent` is the user's/agent's assertion; the CLI has not inspected Sent mail. Arming can miss an immediate recipient fetch, so arm before the controlled recipient-viewing step. Add `--armed` when creating a draft to choose immediate tracking instead, including possible draft-preview notifications. + +## Retries and recovery + +Choose a new `--operation` for each logical email, and reuse it only for retries of identical input. Changed recipient, subject, body, account, Worker or initial arming setting is rejected for the same ID. Journals contain hashes and draft/tracking identifiers, not message bodies or tokens. + +Completed operations return their existing result. A timeout or 5xx can leave Gmail creation ambiguous; the CLI does not blindly repeat that POST: + +```sh +pnpm --silent gmail status --operation hello-001 +# After inspecting Gmail/API and finding the actual existing draft ID: +pnpm --silent gmail verify --operation hello-001 --draft-id EXISTING_DRAFT_ID +``` + +Draft IDs differ from their underlying message IDs. Draft disappearance after sending is normal. This CLI does not retrieve inbox or Sent MIME. After a process crash, inspect the operation's JSON journal and Gmail, and confirm the process stopped before removing that operation's stale `.lock`. Do not delete journals and recreate drafts blindly. + +```sh +pnpm gmail auth logout +# Also revoke the Google grant (can affect other clients in the same project): +pnpm gmail auth logout --revoke +``` + +## Owner views and Gmail proxy limits + +- Pre-arm fetches are retained with `before_armed` and included in `open_count`, preserving the API's audit history. They do not notify or increase `eligible_open_count`. Arming does not delete owner fetches. +- Gmail's image proxy does not carry the sender's cookies, API Authorization header or actual IP. Owner-cookie/IP signals help direct requests; they cannot reliably identify Gmail draft/Sent views. +- **An unarmed response can still fill a proxy cache.** Later arming cannot force another request. Sent-folder views after arming can still notify. Timing/UA heuristics do not establish readership. +- [Google describes](https://support.google.com/mail/answer/145919?hl=en) automatic images, safety checks and cookie restrictions. A new request for every Web/iOS view or repeat open is not guaranteed. + +## Manual acceptance test + +Use **a fresh email, operation ID and pixel for each platform**. Opening one message sequentially in Web and iOS may reuse a cache. + +1. Complete OAuth setup and confirm the sender account. +2. Create a fresh draft to a **different Gmail account you can operate**. Retain the JSON's `state:"verified"` and IDs. +3. If editing in Gmail, save/close and run `verify`. Do not send after a missing-image error. +4. Send manually. Where existing access permits, inspect the sender's/recipient's original raw MIME or Gmail's Show original for the image. Draft verification alone is not recipient-MIME verification. +5. Run `arm --confirm-sent`, then capture history counts and the last event before recipient viewing. +6. Open once in the receiving account's Gmail Web. Compare new event IDs, UTC timestamps, flags and delivery status. When configured, check signatures and event IDs at the webhook receiver. +7. Repeat steps 2–6 with a fresh message, this time opening in the receiving account's iOS Gmail app. Record OS/app versions. +8. Test repeat openings separately. No new fetch does not prove unread status or image removal. + +Automated CLI tests simulate Gmail/redpixels HTTP boundaries; they do not establish real consent, delivery or mobile viewing. A separate live trial on **2026-10-16** used this new CLI, a deployed tracking Worker and a signature-verifying webhook receiver: + +- Desktop OAuth with an Internal Workspace app completed, credentials were saved to macOS Keychain, and separate CLI processes refreshed access and created two raw-MIME drafts. Gmail read-back verified each saved image. Each history started at zero. +- A human sent both drafts to a different Gmail account. During composition/sending, each pixel received one Google image proxy fetch before arming: it remained in history with `before_armed` and produced no notification. +- After manual arming, the tester reported opening the Web test in Gmail Web and the separate iOS test in Gmail for iOS. Each history increased **1 → 2**, each eligible count became **1**, and each new event matched a signature-verified webhook accepted with **HTTP 204**. Recorded request times were **11:24:43.849Z** (Web test) and **11:24:45.841Z** (iOS test). + +Device attribution relies on the tester's report; both requests carried Google image proxy evidence, which does not identify the actual device or prove readership. Recipient raw MIME and exact iOS/Gmail versions were not collected for this trial. Windows/Linux credential backends, repeat opens, replies, post-arm owner views and real Apple MPP behavior remain unverified. The earlier [mobile preservation record](mobile-test.md) used a different drafting path. diff --git a/docs/gmail-connectors.ja.md b/docs/gmail-connectors.ja.md new file mode 100644 index 0000000..6499f8b --- /dev/null +++ b/docs/gmail-connectors.ja.md @@ -0,0 +1,49 @@ +# 既存の Gmail 接続を使う + +[English](gmail-connectors.md) | **日本語** · [README](../README.ja.md) + +まず、エージェントがすでに持つ Gmail 接続を使います。対応するコネクターなら、別の Google Cloud プロジェクト・クライアント JSON・redpixels 専用の Google ログインを追加せずに追跡付き下書きを作れます。Gmail の認証は接続元のサービスが管理します。追跡 Worker は自分の Cloudflare に置き、Google の認証情報は渡しません。 + +**コネクターの実際の機能を確認してください。** `htmlBody` で画像を除去するものもあれば、MIME ツリーや raw メッセージを受け取り画像を保持するものもあります。引数の名前や作成成功の応答だけでは画像保持を確認できません。Gmail に保存されたメッセージを読み戻して確認します。 + +## エージェントの処理 + +1. 接続中の Gmail アカウントと、下書き作成ツールの入力仕様を確認します。コネクター内部のトークンは取り出しません。 +2. redpixels に宛先・件名・`armed:false` を登録します。論理的なメールごとに固有の `Idempotency-Key` を使い、返された追跡 ID・画像 URL・`pixel_html` を保持します。 +3. その HTML を本文内へ外部画像として追加します。コネクターが対応する MIME バイト列または raw メッセージの入力を使います。通常の `htmlBody` 欄へエンコード済み文字列をそのまま渡してはいけません。 +4. 未送信の下書きを作り、Gmail の下書き ID と、その中のメッセージ ID を保存します。タイムアウトなどで結果が不明な場合は調べてから再試行します。redpixels の重複登録防止では Gmail 下書きの重複作成は防げません。 +5. 可能なら Gmail の `format:raw` で保存済みメッセージを取得し、RFC 2822・転送エンコード・HTML MIME パートを復号します。返された画像 URL の実際の `` が必要です。スニペット・プレーンテキスト・コネクターが生成したプレビューだけでは不十分です。画像の存在確認のためにピクセル URL へアクセスしてはいけません。 +6. 検証に成功してから下書きを返します。編集時も画像を保持し、編集後は再検証します。画像が削除される場合や保存済み MIME を読めない場合は、互換性の不足を報告し、送信や追跡成功の扱いをしません。 +7. 人が Gmail で確認して送信します。その申告後、Worker の API トークンで `POST /v1/messages//arm` を呼びます。ID の対応はエージェントが保持します。CLI の `gmail arm --operation` は、その CLI が作成した操作にだけ使えます。 + +従来の HTTP API の既定は即時追跡ですが、このフローでは明示的に `armed:false` を指定します。開始前の取得は `before_armed` として履歴に残り、通知しません。ただし下書きの画像がプロキシにキャッシュされることや、開始後の自分の送信済み閲覧が記録されることはあります。追跡開始だけでキャッシュや読了判定の問題は解消しません。 + +## 確認できたコネクターの入力 + +2026-10-16 の試験時に使えた Codex の Gmail コネクターは、次の形式を受け付けました。 + +```json +{ + "to": "recipient@example.com", + "subject": "Hello", + "payload": { + "mime_type": "text/html", + "charset": "UTF-8", + "body": { + "base64_url_content": "BASE64URL_OF_HTML_WITH_PIXEL" + } + } +} +``` + +これは**入力仕様の例**です。仮の値をそのまま送らず、画像入りの UTF-8 HTML 本文を base64url エンコードした値へ置き換えます。この欄に入るのは1つの HTML MIME パートです。Gmail REST API の raw 経路では、RFC 2822 メッセージ**全体**をエンコードして `message.raw` に入れます。使用するコネクターの実際の仕様に従ってください。 + +実試験では既存の接続で未送信の下書きを1通作り、同じ接続の `format:raw` で読み戻しました。復号した HTML は実際の画像タグを含めて送信した HTML と一致しました。任意の CLI 用に作った OAuth・キーチェーンの認証情報は使っていません。この経路のための追加の Google Cloud 設定もありません。**この新しい下書きは送信・受信者閲覧を行っていないため、今回確認したのは保存時の画像保持までです。** 従来の[モバイル試験](mobile-test.md)や、別経路の [CLI の受入試験](gmail-cli.ja.md#手動の受入テスト)とは範囲が異なります。 + +他サービスの `htmlBody` コネクターが同じように動くという意味ではありません。コネクターの機能は変わり得るため、セットアップ時に新しい下書きの保存済み MIME を確認し、全体の互換性を判断する際は別アカウントでの配信・開封・Webhook も試してください。 + +## 画像を保持できる接続がない場合 + +追跡 Worker のデプロイは完了させ、下書き連携は未完了と報告します。MIME に対応した、利用環境で正式に使える Gmail 接続が設定の少ない代案です。[直接接続する CLI](gmail-cli.ja.md) は、自分の Desktop OAuth クライアントを使うと明示的に選んだ場合の上級者向け代替です。現状は Google Cloud 設定が必要なので、redpixels のデプロイを頼まれただけで自動的にその設定を始めないでください。 + +Google の[下書きの MIME 手順](https://developers.google.com/workspace/gmail/api/guides/drafts)と [raw メッセージの表現](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/get)も参照してください。 diff --git a/docs/gmail-connectors.md b/docs/gmail-connectors.md new file mode 100644 index 0000000..aece049 --- /dev/null +++ b/docs/gmail-connectors.md @@ -0,0 +1,49 @@ +# Use your existing Gmail connection + +**English** | [日本語](gmail-connectors.ja.md) · [README](../README.md) + +Start with the Gmail connection your agent already has. A compatible connector can create tracked drafts without a separate Google Cloud project, client JSON or redpixels-specific Google sign-in. Its host manages Gmail authorization. The tracking Worker still runs in your own Cloudflare account and never receives Google credentials. + +**Check the connector's actual capabilities.** Some `htmlBody` tools remove images. Other connectors support MIME trees or complete raw messages and preserve them. Neither the parameter name nor a successful creation response proves preservation: read the saved Gmail message back and check the image. + +## Agent workflow + +1. Confirm the connected Gmail identity and inspect the draft tool's input contract. Do not extract private connector tokens. +2. Register one message with redpixels using a stable `Idempotency-Key`, recipient, subject and `armed:false`. Keep the returned message ID, pixel URL and `pixel_html`. +3. Insert that HTML into the email's HTML body as a remote image. Use the connector's supported MIME-byte or raw-message input. Do not pass encoded text to a plain `htmlBody` field. +4. Create an unsent draft. Save both the Gmail draft ID and its underlying message ID. A timeout or uncertain result requires inspection before retrying; redpixels idempotency does not prevent duplicate Gmail drafts. +5. Read the saved message using Gmail `format:raw` if available. Decode the RFC 2822 message, its content-transfer encoding and the HTML MIME part. Require a real `` with the returned pixel URL. Checking a snippet, plaintext body or connector-generated preview is insufficient. Do not fetch the pixel to verify its presence. +6. Return the draft only after that check succeeds. Preserve the pixel and repeat verification after editing. If the connector removes it or cannot expose stored MIME, report the compatibility gap; do not silently send or claim tracking works. +7. Let the human review and press Send in Gmail. After confirmation, call `POST /v1/messages//arm` with the Worker's API token. The agent must retain the mapping; CLI `gmail arm --operation` only works for operations created by that CLI. + +The existing API defaults to immediate tracking; this workflow deliberately opts into `armed:false`. Pre-arm fetches stay in history with `before_armed` and do not notify. A proxy can still cache a draft preview, and owner Sent views after arming can still record events. Arming does not establish readership or eliminate caching. + +## Verified connector input + +The Codex Gmail connector available during the 2026-10-16 trial accepted this shape: + +```json +{ + "to": "recipient@example.com", + "subject": "Hello", + "payload": { + "mime_type": "text/html", + "charset": "UTF-8", + "body": { + "base64_url_content": "BASE64URL_OF_HTML_WITH_PIXEL" + } + } +} +``` + +This is a **schema example**, not a literal request. Replace the placeholder with the base64url-encoded UTF-8 HTML body, including the returned image. This field holds one HTML MIME part; a raw Gmail REST request instead encodes the **whole** RFC 2822 message in `message.raw`. Follow the specific connector's documented schema. + +The trial used the existing connector to create one unsent draft, then read `format:raw` through the same connection. The decoded HTML matched the submitted HTML, including the real image. The optional CLI's OAuth and keychain credentials were not used. No additional Google Cloud setup was performed for this path. **This fresh draft was not sent or opened by a recipient**, so this trial establishes stored-image preservation only. Earlier [mobile trials](mobile-test.md) and the separate [CLI acceptance trial](gmail-cli.md#manual-acceptance-test) have different scopes. + +The result does not establish compatibility with another host's `htmlBody` connector. Connector capabilities can change. Include a fresh saved-MIME check during setup and a controlled separate-account delivery/open/webhook test before declaring end-to-end compatibility. + +## When the existing connector cannot preserve images + +Keep the tracker deployment usable and report draft integration as pending. A host-supported Gmail connection with MIME support is the lowest-setup alternative. The [direct Gmail CLI](gmail-cli.md) is an optional advanced fallback for users who explicitly choose their own Desktop OAuth client; it currently requires Google Cloud setup. Do not start that setup automatically merely because a user asks to deploy redpixels. + +See Google's [draft MIME procedure](https://developers.google.com/workspace/gmail/api/guides/drafts) and [raw message representation](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/get). diff --git a/package.json b/package.json index e04be75..faadb3d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "packageManager": "pnpm@10.17.1", "scripts": { + "gmail": "node --experimental-strip-types cli/gmail.mts", "dev": "wrangler dev --config wrangler.toml", "deploy": "wrangler deploy --config wrangler.toml", "dev:cf": "cf dev", @@ -40,5 +41,10 @@ "typescript": "5.9.3", "vitest": "5.0.5", "wrangler": "4.151.0" + }, + "dependencies": { + "@napi-rs/keyring": "2.1.1", + "google-auth-library": "11.3.1", + "parse5": "8.0.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4855df2..1b63757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,16 @@ settings: importers: .: + dependencies: + '@napi-rs/keyring': + specifier: 2.1.1 + version: 2.1.1 + google-auth-library: + specifier: 11.3.1 + version: 11.3.1 + parse5: + specifier: 8.0.1 + version: 8.0.1 devDependencies: '@cloudflare/vitest-plugin': specifier: 1.4.3 @@ -411,6 +421,82 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@napi-rs/keyring-darwin-arm64@2.1.1': + resolution: {integrity: sha512-H6uxAztj6GSDwnh/1JDHCtAqWvBTr+0+FGwOOw+rY568z7vFOqrJTTboePFuAnJSO7kNvq+yuyXJ9B5Ueco4yA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@2.1.1': + resolution: {integrity: sha512-RcHBNnOIzqliNU8Z0jc71bPs/PRn4IIO02XE6DV3HxVFAHo4CooNXDySCL+jbHhObLlSy9yynRn+wq5k+IAmRw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@2.1.1': + resolution: {integrity: sha512-IrM8IKBZKQzETI1BlvSpT6lRzC9eTIQ5TgUwcbQSiQTuXTEXAAHHvBYkoHicN2b2uqgAfO3dwN382CGRw0Ownw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@2.1.1': + resolution: {integrity: sha512-KMld1c9TRucQYV/qasA5jS/VZWCpxMOeHflrciOAsfhiVQ8VfA77grlhGD0kiWtz5e+2M6kYLH9iFmQhPROkGw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@2.1.1': + resolution: {integrity: sha512-AcMk+ZDP8H20Ml5vaTuSh/Ibh5vU4lrAavS2tjoJ7EJJBLDYssONINWrvVgy43SfzntgQVDBcU5GlMI+z5IMFA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-arm64-musl@2.1.1': + resolution: {integrity: sha512-wUHecqT9SMUaQCsJO6vtFEsMcfskspXf6+J0AtKsnK8/GnAAb4hf2FHtwp2ZUOecOk1kTW3LPhU5LwTMJ1LLeA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-riscv64-gnu@2.1.1': + resolution: {integrity: sha512-hrfvuY0zxrpJOa4k/LzWkrB/R/nXnniz/I2V39X5pWgxKJ1iZBQI03/wT8OI7BwSv9czSh5LLeX/o5D5ywRHBg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/keyring-linux-x64-gnu@2.1.1': + resolution: {integrity: sha512-NHfMyYyu6laqRBzgfsB0zN12l+z5gz6xQqmLDT8ijevMMOc9ICcVo2GUJCPSX5WvRBgzui91D4R4QG0tahMurw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-linux-x64-musl@2.1.1': + resolution: {integrity: sha512-5E2pO2zqOv+HJr2YorAUYmqItv8ts2oa+BccxZ07/I7x3XvxqwZGtRbkp4wM8psv7phhIfgy+0kZ/ItBsGiDhg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-win32-arm64-msvc@2.1.1': + resolution: {integrity: sha512-gn+Xg3oZmyC/pMIU0HTCyrIsncn2/u47H3JRQ8WKfPgS0KDCytcf2kuCCSShtNETSE5V8Zc18ymgIW5UrOQ5OA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@2.1.1': + resolution: {integrity: sha512-DeMQnalb+ueYjskBBUjwv0b9xuN9vhs/vi2H4q/crERfDWgmqu77rGZOW4Bi9kDc7Za3t29yeZctEBZzjAtXuA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@2.1.1': + resolution: {integrity: sha512-DjRshMlsMdEyZTNpqfQYj60kDrCZLkl4y80U3w5WJQIj7ypToj02+A/hfJw/g68wzbSZ6uXhklyITQ1DABZAUg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@2.1.1': + resolution: {integrity: sha512-7HUq8FHgW/O4s8ESvkxngTC9tOR/IyktdR0ral2VD0C8HDZk86FwPAH4qZyerKwS1DhJ6AhoebIsxcyw+Lbt1Q==} + engines: {node: '>= 10'} + '@oxc-project/types@0.154.0': resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} @@ -549,13 +635,26 @@ packages: '@vitest/spy@5.0.5': resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + cf@1.0.0-beta.17: resolution: {integrity: sha512-Lw7BF96BwJVY2f/93XuikgRNe+klVr+happT37JpmX6J0XiM+HXGiVt6ZmyWeUGNmBkCFVN5zWimiqq6GgdJrg==} engines: {node: '>=22'} @@ -572,10 +671,30 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -594,6 +713,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -603,11 +725,48 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gaxios@8.3.1: + resolution: {integrity: sha512-h4/R7ay7JFXU5kmAkp/Y4Y4Vmm7m83tD579GUgfQQsRRGQ4TzSedyHla6XgTWohQuVffT3ldN4FF6uCqlRGaEA==} + engines: {node: '>=22'} + + gcp-metadata@9.2.1: + resolution: {integrity: sha512-5/LvxarRKbGCA5LJ+ha2aV1iVm7/aALIW4PzeXnQR5dyXiNMWbeA4UOBnqERInLfH/xSFp0ZREcxodUzxhEw2Q==} + engines: {node: '>=22'} + + google-auth-library@11.3.1: + resolution: {integrity: sha512-mQS0R2Ev/DKkzuWSYtDeq15uhDeaWuT2qzLYqJ41LmooxVKHmn5ut3pc6dlHY6wfHW/D7FBmOYjAtkjaD1A7qA==} + engines: {node: '>=22'} + + google-logging-utils@2.1.0: + resolution: {integrity: sha512-1R6uiq6XhXW2ZrcGtZpGgVDsYGzSmveVOn8Pp37qe1fbThHZ34e3t3eXj5mVu6PFSVx9fvB1KNVnYgVtq/BRcA==} + engines: {node: '>=22'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -689,15 +848,30 @@ packages: resolution: {integrity: sha512-Cjrs+vHHQ7krLO4HYELYpYBJBuMhcnNZD/Z5z/UcQSnFqWSQY3DhtTCFalXgI1/yO1iK+YrRKK8EYkumrEopxg==} engines: {node: '>=22.0.0'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + obug@3.0.0: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -725,6 +899,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -864,6 +1041,10 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + why-is-node-running@3.2.1: resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} engines: {node: '>=20.11'} @@ -1168,6 +1349,57 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@napi-rs/keyring-darwin-arm64@2.1.1': + optional: true + + '@napi-rs/keyring-darwin-x64@2.1.1': + optional: true + + '@napi-rs/keyring-freebsd-x64@2.1.1': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@2.1.1': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@2.1.1': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@2.1.1': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@2.1.1': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@2.1.1': + optional: true + + '@napi-rs/keyring-linux-x64-musl@2.1.1': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@2.1.1': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@2.1.1': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@2.1.1': + optional: true + + '@napi-rs/keyring@2.1.1': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 2.1.1 + '@napi-rs/keyring-darwin-x64': 2.1.1 + '@napi-rs/keyring-freebsd-x64': 2.1.1 + '@napi-rs/keyring-linux-arm-gnueabihf': 2.1.1 + '@napi-rs/keyring-linux-arm64-gnu': 2.1.1 + '@napi-rs/keyring-linux-arm64-musl': 2.1.1 + '@napi-rs/keyring-linux-riscv64-gnu': 2.1.1 + '@napi-rs/keyring-linux-x64-gnu': 2.1.1 + '@napi-rs/keyring-linux-x64-musl': 2.1.1 + '@napi-rs/keyring-win32-arm64-msvc': 2.1.1 + '@napi-rs/keyring-win32-ia32-msvc': 2.1.1 + '@napi-rs/keyring-win32-x64-msvc': 2.1.1 + '@oxc-project/types@0.154.0': {} '@poppinss/colors@4.1.6': @@ -1257,10 +1489,18 @@ snapshots: '@vitest/spy@5.0.5': {} + agent-base@7.1.4: {} + assertion-error@2.0.1: {} + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + blake3-wasm@2.1.5: {} + buffer-equal-constant-time@1.0.1: {} + cf@1.0.0-beta.17(@types/node@22.15.17): dependencies: '@cloudflare/build-output-utils': 0.9.2 @@ -1279,8 +1519,20 @@ snapshots: cookie@1.1.1: {} + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + detect-libc@2.1.2: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + entities@8.1.0: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@2.3.2: {} @@ -1320,13 +1572,75 @@ snapshots: expect-type@1.4.0: {} + extend@3.0.2: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fsevents@2.3.3: optional: true + gaxios@8.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@9.2.1: + dependencies: + gaxios: 8.3.1 + google-logging-utils: 2.1.0 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + google-auth-library@11.3.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 8.3.1 + gcp-metadata: 9.2.1 + google-logging-utils: 2.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@2.1.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + kleur@4.1.5: {} lightningcss-android-arm64@1.33.0: @@ -1395,10 +1709,24 @@ snapshots: - bufferutil - utf-8-validate + ms@2.1.3: {} + nanoid@3.3.20: {} + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + obug@3.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -1436,6 +1764,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.14 '@rolldown/binding-win32-x64-msvc': 1.2.14 + safe-buffer@5.2.1: {} + semver@7.8.5: {} sharp@0.35.5(@types/node@22.15.17): @@ -1532,6 +1862,8 @@ snapshots: transitivePeerDependencies: - msw + web-streams-polyfill@3.3.3: {} + why-is-node-running@3.2.1: {} workerd@1.20261012.1: diff --git a/test/node/gmail-cli.test.mts b/test/node/gmail-cli.test.mts new file mode 100644 index 0000000..4ab887c --- /dev/null +++ b/test/node/gmail-cli.test.mts @@ -0,0 +1,517 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtemp, + rm, + stat, + chmod, + symlink, + readFile, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + GMAIL_API, + GMAIL_SCOPE, + type CredentialStore, + type Fetcher, + type GmailCredential, + type DraftInput, +} from "../../cli/contracts.mts"; +import { + armDraft, + createTrackedDraft, + hasPixel, + verifyDraft, +} from "../../cli/drafts.mts"; +import { runCli } from "../../cli/gmail.mts"; +import { FileCredentialStore, OperationStore } from "../../cli/storage.mts"; + +const origin = "https://pixels.example"; +const id = "11111111-1111-4111-8111-111111111111"; +const pixel = `${origin}/p/${id}.gif`; +const image = ``; +const fake: GmailCredential = { + version: 1, + clientId: "test.apps.googleusercontent.com", + clientSecret: "fake-client-secret", + refreshToken: "fake-refresh-token", + email: "sender@example.com", + scopes: [GMAIL_SCOPE], +}; +class MemoryCredentials implements CredentialStore { + value: GmailCredential | null = structuredClone(fake); + async read() { + return this.value ? structuredClone(this.value) : null; + } + async write(value: GmailCredential) { + this.value = structuredClone(value); + } + async remove() { + this.value = null; + } +} +function response(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": "application/json" }, + }); +} +function htmlFromMime(raw: string) { + const mime = Buffer.from(raw, "base64url").toString(); + const boundary = /boundary="([^"]+)"/.exec(mime)![1]!; + const html = mime.split(`--${boundary}`)[2]!.split("\r\n\r\n")[1]!.trim(); + return Buffer.from(html, "base64").toString(); +} +async function fixture() { + const home = await mkdtemp(join(tmpdir(), "redpixels-gmail-test-")); + const credentials = new MemoryCredentials(); + const operations = new OperationStore(home); + const calls: { url: string; init?: RequestInit }[] = []; + const state = { + stripImage: false, + createFailure: 0, + networkFailure: false, + html: "", + raw: "", + created: 0, + registered: 0, + armed: 0, + account: fake.email, + }; + const fetcher: Fetcher = async (url, init) => { + calls.push({ url, ...(init ? { init } : {}) }); + assert.ok( + !url.includes(fake.refreshToken) && !url.includes(fake.clientSecret), + ); + if (url === "https://oauth2.googleapis.com/token") { + const params = new URLSearchParams(String(init?.body)); + assert.equal(params.get("grant_type"), "refresh_token"); + assert.equal(params.get("refresh_token"), fake.refreshToken); + return response({ + access_token: "fake-access-token", + expires_in: 3600, + scope: GMAIL_SCOPE, + }); + } + if (url === `${GMAIL_API}/profile`) + return response({ emailAddress: state.account }); + if (url === `${origin}/v1/messages`) { + state.registered++; + return response({ id, pixel_url: pixel, pixel_html: image }, 201); + } + if (url === `${GMAIL_API}/drafts`) { + state.created++; + const body = JSON.parse(String(init?.body)) as { + message: { raw: string }; + }; + assert.deepEqual(Object.keys(body), ["message"]); + assert.deepEqual(Object.keys(body.message), ["raw"]); + state.raw = body.message.raw; + state.html = htmlFromMime(state.raw); + if (state.networkFailure) throw new Error("network response lost"); + if (state.createFailure) + return response( + { error: "example Gmail rejection" }, + state.createFailure, + ); + return response({ id: "draft-1", message: { id: "message-1" } }); + } + if (url === `${GMAIL_API}/drafts/draft-1?format=full`) { + const html = state.stripImage + ? state.html.replace(/]*>/g, "") + : state.html; + return response({ + id: "draft-1", + message: { + id: "message-1", + payload: { + mimeType: "multipart/alternative", + headers: [{ name: "From", value: fake.email }], + parts: [ + { mimeType: "text/plain", body: { data: "" } }, + { + mimeType: "multipart/related", + parts: [ + { + mimeType: "text/html", + body: { data: Buffer.from(html).toString("base64url") }, + }, + ], + }, + ], + }, + }, + }); + } + if (url === `${origin}/v1/messages/${id}/arm`) { + state.armed++; + return response({ id, armed_at: new Date().toISOString() }); + } + throw new Error(`Unexpected HTTP call: ${url}`); + }; + const services = { + fetcher, + credentials, + operations, + tracker: { origin, token: "fake-redpixels-token-with-32-characters" }, + }; + const input: DraftInput = { + operation: "email-001", + to: "reader@example.com", + subject: "日本語 👀", + html: "

Hello 世界

", + text: "Hello 世界", + armed: false, + }; + return { + home, + calls, + state, + services, + input, + cleanup: () => rm(home, { recursive: true, force: true }), + }; +} + +test("one CLI command registers, sends raw MIME, verifies saved pixel, and returns JSON without secrets", async () => { + const f = await fixture(); + try { + const path = join(f.home, "email.html"); + await writeFile(path, f.input.html); + let output = ""; + const code = await runCli( + [ + "draft", + "--to", + f.input.to, + "--subject", + f.input.subject, + "--html", + path, + "--operation", + f.input.operation, + ], + { + env: { + REDPIXELS_URL: origin, + REDPIXELS_API_TOKEN: f.services.tracker.token, + REDPIXELS_GMAIL_HOME: f.home, + }, + fetcher: f.services.fetcher, + credentials: f.services.credentials, + stdout: (text) => { + output += text; + }, + }, + ); + assert.equal(code, 0, output); + const parsed = JSON.parse(output) as Record; + assert.equal(parsed.state, "verified"); + assert.equal(parsed.armed, false); + assert.equal(parsed.gmail_draft_id, "draft-1"); + assert.equal(parsed.tracking_id, id); + assert.ok( + f.state.html.includes(image) && f.state.html.includes("Hello 世界"), + ); + assert.ok( + !output.includes(fake.refreshToken) && + !output.includes("fake-access-token") && + !output.includes(fake.clientSecret) && + !output.includes(f.services.tracker.token), + ); + const registration = f.calls.find( + (call) => call.url === `${origin}/v1/messages`, + )!; + assert.equal( + (JSON.parse(String(registration.init?.body)) as { armed: boolean }).armed, + false, + ); + assert.equal( + new Headers(registration.init?.headers).get("Idempotency-Key"), + "gmail-email-001", + ); + assert.ok( + !f.calls.some((call) => call.url.includes("/send") || call.url === pixel), + ); + if (process.platform !== "win32") + assert.equal( + (await stat(join(f.home, "operations", "email-001.json"))).mode & 0o777, + 0o600, + ); + } finally { + await f.cleanup(); + } +}); + +test("retries reuse a completed draft; changed input cannot reuse the operation", async () => { + const f = await fixture(); + try { + const first = await createTrackedDraft(f.services, f.input); + assert.deepEqual(await createTrackedDraft(f.services, f.input), first); + assert.equal(f.state.created, 1); + assert.equal(f.state.registered, 1); + await assert.rejects( + createTrackedDraft(f.services, { ...f.input, html: "

Changed

" }), + /different input/, + ); + assert.equal(f.state.created, 1); + } finally { + await f.cleanup(); + } +}); + +test("connector-style stripping fails closed, retaining IDs for verification and recovery", async () => { + const f = await fixture(); + try { + f.state.stripImage = true; + await assert.rejects( + createTrackedDraft(f.services, f.input), + /missing the tracking image/, + ); + const saved = await f.services.operations.read(f.input.operation); + assert.equal(saved?.state, "verification_failed"); + assert.equal(saved?.draftId, "draft-1"); + await assert.rejects( + armDraft(f.services, f.input.operation, true), + /verified draft/, + ); + f.state.stripImage = false; + assert.equal( + (await verifyDraft(f.services, f.input.operation)).state, + "verified", + ); + assert.equal(f.state.created, 1); + await assert.rejects( + armDraft(f.services, f.input.operation, false), + /confirm-sent/, + ); + const armed = await armDraft(f.services, f.input.operation, true); + assert.equal(armed.armed, true); + assert.equal(armed.sent_status, "user_confirmed_not_verified"); + assert.match(armed.next_step, /Tracking is armed/); + await armDraft(f.services, f.input.operation, true); + assert.equal(f.state.armed, 1); + } finally { + await f.cleanup(); + } +}); + +for (const failure of ["network", "server"] as const) { + test(`an ambiguous ${failure} failure never blindly repeats Gmail draft creation`, async () => { + const f = await fixture(); + try { + if (failure === "network") f.state.networkFailure = true; + else f.state.createFailure = 503; + await assert.rejects(createTrackedDraft(f.services, f.input)); + assert.equal( + (await f.services.operations.read(f.input.operation))?.state, + "ambiguous", + ); + f.state.networkFailure = false; + f.state.createFailure = 0; + await assert.rejects( + createTrackedDraft(f.services, f.input), + /uncertain/, + ); + assert.equal(f.state.created, 1); + assert.equal( + (await verifyDraft(f.services, f.input.operation, "draft-1")).state, + "verified", + ); + assert.equal(f.state.created, 1); + } finally { + await f.cleanup(); + } + }); +} +test("a definite Gmail rejection can retry without another tracker registration", async () => { + const f = await fixture(); + try { + f.state.createFailure = 400; + await assert.rejects(createTrackedDraft(f.services, f.input), /HTTP 400/); + assert.equal( + (await f.services.operations.read(f.input.operation))?.state, + "registered", + ); + f.state.createFailure = 0; + assert.equal( + (await createTrackedDraft(f.services, f.input)).state, + "verified", + ); + assert.equal(f.state.registered, 1); + assert.equal(f.state.created, 2); + } finally { + await f.cleanup(); + } +}); +test("concurrent calls for one operation cannot create duplicate drafts", async () => { + const f = await fixture(); + try { + const results = await Promise.allSettled([ + createTrackedDraft(f.services, f.input), + createTrackedDraft(f.services, f.input), + ]); + assert.ok(results.some((item) => item.status === "fulfilled")); + assert.equal(f.state.created, 1); + assert.equal(f.state.registered, 1); + } finally { + await f.cleanup(); + } +}); +test("invalid addresses, header injection and path traversal make no HTTP calls", async () => { + const f = await fixture(); + try { + for (const input of [ + { ...f.input, subject: "Hi\r\nBcc: attacker@example.com" }, + { ...f.input, to: "a@example.com,b@example.com" }, + { ...f.input, operation: "../escape" }, + ]) + await assert.rejects(createTrackedDraft(f.services, input)); + assert.equal(f.calls.length, 0); + } finally { + await f.cleanup(); + } +}); +test("a refreshed token for another account is rejected before tracking or Gmail mutation", async () => { + const f = await fixture(); + try { + f.state.account = "other@example.com"; + await assert.rejects( + createTrackedDraft(f.services, f.input), + /account changed/, + ); + assert.equal(f.state.registered, 0); + assert.equal(f.state.created, 0); + } finally { + await f.cleanup(); + } +}); +test("immediate tracking is an explicit option; malformed HTML cannot hide a missing pixel", async () => { + const f = await fixture(); + try { + const value = await createTrackedDraft(f.services, { + ...f.input, + armed: true, + }); + assert.equal(value.armed, true); + const registered = f.calls.find( + (call) => call.url === `${origin}/v1/messages`, + )!; + assert.equal( + (JSON.parse(String(registered.init?.body)) as { armed: boolean }).armed, + true, + ); + await assert.rejects( + createTrackedDraft(f.services, { + ...f.input, + operation: "bad-html", + html: "`, + ``, + ``, + `<img src="${pixel}">`, + ``, + ``, + "

No image

", + ]) + assert.equal(hasPixel(html, pixel), false, html); +}); +test("file credential storage is private, rejects unsafe permissions/symlinks and removes only its entry", async () => { + const f = await fixture(); + try { + const store = new FileCredentialStore(f.home); + assert.equal(await store.read(), null); + await store.write(fake); + assert.deepEqual(await store.read(), fake); + if (process.platform !== "win32") + assert.equal((await stat(store.path)).mode & 0o777, 0o600); + const raw = await readFile(store.path, "utf8"); + assert.ok(!raw.includes("fake-access-token")); + if (process.platform !== "win32") { + await chmod(store.path, 0o644); + await assert.rejects(store.read(), /0600/); + await chmod(store.path, 0o600); + await store.remove(); + await symlink(join(f.home, "unrelated.json"), store.path); + await assert.rejects(store.read()); + await store.remove(); + } else await store.remove(); + assert.equal(await store.read(), null); + } finally { + await f.cleanup(); + } +}); +test("CLI refuses send commands without accessing credentials or making HTTP calls", async () => { + let output = ""; + let touched = false; + const code = await runCli(["send", "--to", "recipient@example.com"], { + fetcher: async () => { + touched = true; + throw new Error("Unexpected call"); + }, + stdout: (text) => { + output += text; + }, + }); + assert.equal(code, 1); + assert.match(output, /no send command/); + assert.equal(touched, false); +}); + +test("failed manual recovery preserves the previously verified draft mapping", async () => { + const f = await fixture(); + try { + await createTrackedDraft(f.services, f.input); + const before = await f.services.operations.read(f.input.operation); + await assert.rejects( + verifyDraft(f.services, f.input.operation, "wrong-draft"), + ); + assert.deepEqual( + await f.services.operations.read(f.input.operation), + before, + ); + assert.equal(f.state.created, 1); + } finally { + await f.cleanup(); + } +}); + +test("a corrupt verified journal cannot report success without Gmail IDs and verification time", async () => { + const f = await fixture(); + try { + await createTrackedDraft(f.services, f.input); + const path = join(f.home, "operations", `${f.input.operation}.json`); + const saved = JSON.parse(await readFile(path, "utf8")); + for (const field of ["draftId", "gmailMessageId", "verifiedAt"]) { + const corrupt = { ...saved }; + delete corrupt[field]; + await writeFile(path, JSON.stringify(corrupt)); + await assert.rejects( + f.services.operations.read(f.input.operation), + /Invalid draft operation/, + ); + } + } finally { + await f.cleanup(); + } +}); diff --git a/test/node/gmail-oauth.test.mts b/test/node/gmail-oauth.test.mts new file mode 100644 index 0000000..1d4f634 --- /dev/null +++ b/test/node/gmail-oauth.test.mts @@ -0,0 +1,244 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + GMAIL_API, + GMAIL_SCOPE, + type Fetcher, + type GmailCredential, + type CredentialStore, +} from "../../cli/contracts.mts"; +import { access, authorize, desktopClient } from "../../cli/oauth.mts"; + +const client = { + clientId: "test.apps.googleusercontent.com", + clientSecret: "fake-desktop-secret", +}; +const credential: GmailCredential = { + version: 1, + ...client, + email: "sender@example.com", + refreshToken: "fake-refresh", + scopes: [GMAIL_SCOPE], +}; +function response(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": "application/json" }, + }); +} +test("Google SDK uses PKCE S256/offline consent, rejects incorrect state, and exchanges a valid loopback callback", async () => { + let authorization!: URL; + let exchanges = 0; + const fetcher: Fetcher = async (url, init) => { + if (url === `${GMAIL_API}/profile`) + return response({ emailAddress: credential.email }); + assert.equal(url, "https://oauth2.googleapis.com/token"); + exchanges++; + const body = new URLSearchParams(String(init?.body)); + assert.equal(body.get("grant_type"), "authorization_code"); + assert.equal(body.get("client_id"), client.clientId); + assert.equal(body.get("client_secret"), client.clientSecret); + assert.equal(body.get("code"), "fake-code"); + assert.equal( + body.get("redirect_uri"), + authorization.searchParams.get("redirect_uri"), + ); + assert.equal( + createHash("sha256") + .update(body.get("code_verifier")!) + .digest("base64url"), + authorization.searchParams.get("code_challenge"), + ); + return response({ + access_token: "fake-access", + refresh_token: "fake-refresh", + expires_in: 3600, + scope: GMAIL_SCOPE, + }); + }; + const saved = await authorize({ + client, + fetcher, + timeoutMs: 5000, + onAuthorize: async (url) => { + authorization = new URL(url); + assert.equal(authorization.origin, "https://accounts.google.com"); + assert.equal(authorization.searchParams.get("scope"), GMAIL_SCOPE); + assert.equal(authorization.searchParams.get("access_type"), "offline"); + assert.equal(authorization.searchParams.get("prompt"), "consent"); + assert.equal( + authorization.searchParams.get("code_challenge_method"), + "S256", + ); + const callback = new URL(authorization.searchParams.get("redirect_uri")!); + assert.equal(callback.hostname, "127.0.0.1"); + callback.search = new URLSearchParams({ + state: "wrong", + code: "fake-code", + }).toString(); + const rejected = await fetch(callback); + assert.equal(rejected.status, 400); + await rejected.text(); + assert.equal(exchanges, 0); + callback.searchParams.set( + "state", + authorization.searchParams.get("state")!, + ); + const accepted = await fetch(callback); + assert.equal(accepted.status, 200); + await accepted.text(); + }, + }); + assert.deepEqual(saved, credential); + assert.ok(!JSON.stringify(saved).includes("fake-access")); + assert.equal(exchanges, 1); +}); +test("denied consent stops without exchanging a token", async () => { + let called = false; + await assert.rejects( + authorize({ + client, + fetcher: async () => { + called = true; + throw new Error("Unexpected token call"); + }, + onAuthorize: async (url) => { + const authorization = new URL(url); + const callback = new URL( + authorization.searchParams.get("redirect_uri")!, + ); + callback.search = new URLSearchParams({ + state: authorization.searchParams.get("state")!, + error: "access_denied", + }).toString(); + const denied = await fetch(callback); + assert.equal(denied.status, 400); + await denied.text(); + }, + }), + /denied/, + ); + assert.equal(called, false); +}); +for (const missing of ["scope", "refresh"] as const) { + test(`missing ${missing} prevents OAuth setup from being reported as connected`, async () => { + let profiles = 0; + await assert.rejects( + authorize({ + client, + fetcher: async (url) => { + if (url.endsWith("/profile")) { + profiles++; + return response({ emailAddress: credential.email }); + } + return response({ + access_token: "fake-access", + expires_in: 3600, + scope: missing === "scope" ? "openid" : GMAIL_SCOPE, + ...(missing === "refresh" ? {} : { refresh_token: "fake-refresh" }), + }); + }, + onAuthorize: async (url) => { + const authorization = new URL(url); + const callback = new URL( + authorization.searchParams.get("redirect_uri")!, + ); + callback.search = new URLSearchParams({ + state: authorization.searchParams.get("state")!, + code: "fake", + }).toString(); + await (await fetch(callback)).text(); + }, + }), + missing === "scope" ? /permission was not granted/ : /refresh token/, + ); + assert.equal(profiles, 0); + }); +} +test("OAuth callback timeout closes the local server without network requests", async () => { + await assert.rejects( + authorize({ + client, + timeoutMs: 30, + onAuthorize: () => {}, + fetcher: async () => { + throw new Error("Should not be called"); + }, + }), + /timed out/, + ); +}); +test("SDK refresh persists rotated refresh tokens and checks the authenticated identity", async () => { + let stored = structuredClone(credential); + const vault: CredentialStore = { + read: async () => structuredClone(stored), + write: async (value) => { + stored = structuredClone(value); + }, + remove: async () => {}, + }; + const refreshed = await access(vault, async (url, init) => { + if (url === `${GMAIL_API}/profile`) { + assert.equal( + new Headers(init?.headers).get("Authorization"), + "Bearer fake-new-access", + ); + return response({ emailAddress: credential.email }); + } + assert.equal(url, "https://oauth2.googleapis.com/token"); + const body = new URLSearchParams(String(init?.body)); + assert.equal(body.get("refresh_token"), credential.refreshToken); + return response({ + access_token: "fake-new-access", + refresh_token: "fake-rotated-refresh", + scope: GMAIL_SCOPE, + expires_in: 3600, + }); + }); + assert.equal(refreshed.accessToken, "fake-new-access"); + assert.equal(stored.refreshToken, "fake-rotated-refresh"); + assert.ok(!JSON.stringify(stored).includes("fake-new-access")); +}); +test("OAuth errors never reveal SDK request bodies, credentials or provider error content", async () => { + const vault: CredentialStore = { + read: async () => credential, + write: async () => {}, + remove: async () => {}, + }; + await assert.rejects( + access(vault, async () => + response( + { + error: "invalid_grant", + error_description: `${client.clientSecret} ${credential.refreshToken}`, + }, + 400, + ), + ), + (error: Error) => { + assert.match(error.message, /refresh failed/); + assert.ok(!error.message.includes(client.clientSecret)); + assert.ok(!error.message.includes(credential.refreshToken)); + return true; + }, + ); +}); +test("only Desktop client JSON is accepted and downloaded endpoint overrides are ignored", () => { + assert.throws(() => + desktopClient({ + web: { client_id: client.clientId, client_secret: client.clientSecret }, + }), + ); + assert.deepEqual( + desktopClient({ + installed: { + client_id: client.clientId, + client_secret: client.clientSecret, + auth_uri: "https://evil.example", + token_uri: "https://evil.example", + }, + }), + client, + ); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index 158b2bc..ef6324d 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,12 +1,14 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "erasableSyntaxOnly": true, "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2023", "DOM"], "types": ["node"] }, "include": [ + "cli/**/*.mts", "examples/**/*.mts", "scripts/**/*.mts", "test/node/**/*.mts",