English | 日本語
redpixels is self-hosted email open history and signed webhooks for Gmail.
- Keep Gmail. No Chrome extension or separate email app. Your AI assistant adds a tiny image when creating an HTML draft through the Gmail API. Review and send in Gmail, including its mobile apps, provided the image survives editing.
- See the whole observed history. Every image request reaching your Worker is saved, with a timestamp, count, relative time, and classification flags.
- Get a webhook. Notify on the first qualifying request or every qualifying request, with signatures and durable retries.
- Host it on your own Cloudflare account. One Worker, built-in SQLite storage, MIT licensed. No Gmail credentials on the tracking server.
An image fetch is evidence, not a verified read. Caches can hide repeat opens; Apple Mail Privacy Protection and previews can fetch without anyone reading. redpixels records every observed fetch, not every actual read. Mobile behavior varies; see the tested device cases and remaining gaps.
The name plays on “read pixels”: the past tense of “read” sounds like “red.”
Choose one prompt for an agent with terminal access and permission to use your Cloudflare account. New installations can use Cloudflare's cf CLI (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.
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.
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.
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 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.
You need a Cloudflare account, Node.js 22.18+ or 24 LTS, and pnpm 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.
git clone https://github.com/arkorlab/redpixels.git
cd redpixels
pnpm install --frozen-lockfile
node --experimental-strip-types examples/create-secrets.mtsThe last command generates a random API token in .env.redpixels.json, with mode 0600, without printing the value. It refuses to overwrite an existing file: reuse your secrets when updating. The file is Git-ignored. Save its API_TOKEN in your password manager and your assistant's secret configuration.
Run one deployment path:
pnpm exec cf auth login
pnpm run deploy:cf --secrets-file .env.redpixels.jsoncf reads cloudflare.config.ts; wrangler.config.ts supplies its bundler settings. It uses its own login, separate from Wrangler. If a browser cannot open locally, use pnpm exec cf auth login --no-browser and approve the displayed link/code. This checkout is already configured: do not run cf init or cf migrate. cf currently uses Wrangler as its bundler here, so the cf path still includes that build dependency.
pnpm exec wrangler login
pnpm run deploy:wrangler --secrets-file .env.redpixels.jsonWrangler reads wrangler.toml; the named scripts select it explicitly. If localhost callbacks are unavailable, the pinned CLI supports pnpm exec wrangler login --device. Existing pnpm dev, pnpm run deploy, and pnpm build commands remain Wrangler aliases.
Either deployment prints the Worker URL, usually https://redpixels.<your-subdomain>.workers.dev. The API fails closed without API_TOKEN. Keep the same account, Worker name, binding, and SQLite class when updating an existing installation; preserve its namespace and history. cf's configuration is beta; live deployment through cf is not yet part of this project's recorded verification. See the cf and Wrangler guides for CLI-specific details.
Set these locally for the examples:
export REDPIXELS_URL='https://redpixels.<your-subdomain>.workers.dev'
export REDPIXELS_API_TOKEN='<your API_TOKEN>'Register an email; the server generates its random, unique pixel URL:
curl --fail-with-body "$REDPIXELS_URL/v1/messages" \
-H "Authorization: Bearer $REDPIXELS_API_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: draft-operation-123' \
--data '{"recipient":"alice@example.com","subject":"Hello"}'The response includes id, pixel_url, and ready-to-embed pixel_html:
<img
src="https://YOUR-WORKER/p/GENERATED-UUID.gif"
width="1"
height="1"
alt=""
style="display:block;border:0;width:1px;height:1px"
/>Use the returned HTML, not these placeholders. Add it inside the draft's HTML body, ideally before </body>. Keep it as a remote image, not a CID attachment, data URL, or literal text. Existing messages and messages composed without the pixel cannot be tracked retroactively.
Use a new Idempotency-Key per email. Retrying the same key and body returns the same pixel; changed metadata returns 409. The header is optional but recommended for AI workflows. One message to several people shares a pixel and cannot distinguish recipients; use separate messages/pixels when that matters.
- Register the email with redpixels.
- Insert
pixel_htmlinto the HTML MIME part. - Base64url-encode the complete MIME message and call Gmail
users.drafts.create. - Review and send in Gmail. Preserve the pixel whenever replacing a draft.
Google's draft guide documents MIME and base64url encoding. redpixels does not read your inbox, obtain Gmail OAuth access, or send mail.
The dependency-free Node example creates a draft without sending it:
export GMAIL_ACCESS_TOKEN='<OAuth access token with gmail.compose permission>'
export GMAIL_FROM='you@example.com'
export GMAIL_TO='alice@example.com'
export GMAIL_SUBJECT='Hello'
export GMAIL_TEXT='Hello Alice!'
node --experimental-strip-types examples/gmail-draft.mts examples/email.htmlReplace 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. 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.
By default tracking starts immediately. A preview can fetch before sending. If your workflow can act after sending, register with "armed":false, then call:
curl --fail-with-body -X POST "$REDPIXELS_URL/v1/messages/<id>/arm" \
-H "Authorization: Bearer $REDPIXELS_API_TOKEN"Pre-arm requests remain in history with before_armed and never notify. Repeated arming preserves the original start time. Manual mobile sending needs a later confirmation step in your assistant; redpixels does not watch Gmail's Sent folder. Arming after sending can miss an immediate recipient fetch, and a preview may already have filled a proxy cache. This is an optional tradeoff, not guaranteed self-open detection.
curl --fail-with-body "$REDPIXELS_URL/v1/messages/<id>/opens?limit=100" \
-H "Authorization: Bearer $REDPIXELS_API_TOKEN"
# Readable timeline, automatically following every page:
node --experimental-strip-types examples/history.mts <id>Example output:
Hello: 2 observed fetches
#1 2026-10-14T09:00:00.000Z 2 days ago [google_image_proxy]
#2 2026-10-16T04:00:00.000Z 5 hours ago [google_image_proxy]
JSON responses contain message metadata/totals, opens, and next_after. Each open includes a unique event_id, UTC timestamp, relative ago, monotonic open_number, ua, country, flags, notification_eligible, and webhook delivery status.
open_count: all recorded GETs, including suspected automation and owner fetches.eligible_open_count: requests qualifying under the filter when recorded; not a human-read estimate.webhook_count: notifications enqueued, not successful deliveries.webhook:nullif no event was queued; otherwisestatus(pending,delivered,failed),attempts,last_status,last_error, anddelivered_at.
Histories are oldest first, including ties, with stable numeric cursors. Follow next_after until null; history is never silently truncated. Page size defaults to 100, maximum 500. Message listing includes emails with zero fetches. Classification and eligibility are stored snapshots, not recomputed after configuration changes.
For cf, add WEBHOOK_SECRET (a different random secret of at least 32 characters), WEBHOOK_URL, and optional WEBHOOK_MODE / WEBHOOK_FILTER to your protected .env.redpixels.json, preserving API_TOKEN. Apply them with pnpm run deploy:cf --secrets-file .env.redpixels.json. cf does not yet provide a single-secret put command.
With Wrangler, the same secrets-file workflow works through deploy:wrangler, or use the interactive commands:
pnpm exec wrangler secret put WEBHOOK_SECRET --config wrangler.toml
pnpm exec wrangler secret put WEBHOOK_URL --config wrangler.toml
# Optional: enter "first" instead of the default "every"
pnpm exec wrangler secret put WEBHOOK_MODE --config wrangler.toml
# Optional: enter "unflagged" instead of the default "all"
pnpm exec wrangler secret put WEBHOOK_FILTER --config wrangler.tomlThese are Worker environment bindings. Both deployment paths keep personal settings out of source. Reuse your secret values on subsequent deployments. A URL without a valid signing secret is a configuration error; unsigned notifications are never sent.
| Setting | Behavior |
|---|---|
every |
Notify for each qualifying fetch. |
first |
Notify for the first qualifying fetch while webhooks are enabled. Owner/pre-arm fetches do not consume it. Delivery failures retry the same event; later opens do not replace it. |
Filter all |
Include suspected MPP, automation and early fetches with flags. Explicit owner/pre-arm fetches are always excluded. |
Filter unflagged |
Also exclude possible MPP, automation and early fetches. Gmail's proxy flag alone is still eligible. Can suppress real reads and cannot guarantee human activity. |
Payload: type: "email.open", version: 1, stable event_id, message_id, recipient, subject, external_id, UTC timestamp, the event's open_count / eligible_open_count, ua, country, and flags.
X-Redpixels-Event-Id: <stable event UUID>
X-Redpixels-Timestamp: <Unix seconds for this attempt>
X-Redpixels-Signature: v1=<hex HMAC-SHA256>
Sign timestamp + "." + raw_request_body with the shared secret. Verify in constant time; reject timestamps more than five minutes old or in the future. Persist deduplication by event_id. Parse JSON only after verification. See the tested Node receiver verifier. Payloads contain email metadata; choose a receiver you control.
Events, counts, outbox rows and alarms commit in one storage transaction. Webhook HTTP runs independently of serving the pixel, with a 10-second timeout per attempt. 2xx acknowledges delivery. Network errors, 408, 429 and 5xx retry with exponential backoff up to eight attempts (roughly eleven minutes plus request time). Other statuses, including redirects, fail permanently. Redirects are never followed. Platform alarms can be delayed.
Delivery uses at-least-once semantics with bounded retries, not exactly once or guaranteed eventual delivery. Ambiguous timeouts or a crash after acceptance can duplicate delivery. Event IDs/payloads stay stable; signing timestamps refresh. The history API exposes delivery failures. delivered means HTTP receiver acceptance, not a person seeing a downstream notification. There is no automatic redrive after exhaustion. Queued jobs keep the original receiver URL and use the current signing secret. Unsetting WEBHOOK_URL stops new jobs; queued jobs still run. Deleting a message cancels queued jobs; a request already in flight cannot be recalled.
Every flag remains visible. None proves who read an email.
| Flag | Evidence and limits |
|---|---|
self_ip |
Direct request matches exact SELF_IPS. Shared networks may flag others. Proxies hide your phone's IP. Raw IPs are never stored. |
self_cookie |
Valid signed owner cookie. Visit /self on your Worker and enter your API token to mark that browser. Gmail/Apple proxies do not forward it; third-party cookie blocking can prevent it. |
self_authenticated |
Image GET has the valid owner Bearer token, useful for controlled tests. Never embed the token in an email or URL. |
before_armed |
Registered with armed:false and not yet armed. |
early_fetch |
Within SELF_WINDOW_SECONDS of registration/arming (default 30). Could be a preview, scanner, or fast recipient; not a confirmed self-open. |
google_image_proxy |
GoogleImageProxy UA. Gmail uses proxies for normal viewing too; this alone does not mean a false read. Caching and reader identity remain opaque. |
possible_apple_mpp |
Bare Mozilla/5.0, or generic WebKit without normal browser/mobile tokens. Broad, incomplete UA heuristic; other clients may match and Apple traffic may not. |
automation_or_prefetch |
Bot/crawler/preview/proxy UA hints or Purpose/Sec-Purpose prefetch/preview headers. Hints can be absent or spoofed. |
Set SELF_IPS to comma-separated exact owner IPv4/IPv6 addresses through your selected CLI's secrets workflow. CIDRs are unsupported. Do not add shared Google/Apple proxy networks. SELF_WINDOW_SECONDS=0 disables the time heuristic. Later owner opens, especially through Gmail's proxy, can remain indistinguishable from recipient activity.
One real iPhone running iOS 26 was tested on October 16, 2026 (Gmail app version not recorded). A Gmail API HTML draft retained its 1px image after a text edit, saving, and sending from the Gmail app. Decoding the received raw MIME confirmed both the edited text and the original tracking URL in the image's src. In the saved draft, Gmail temporarily changed src to a Google proxy URL with the original URL in its fragment; sending restored the original src.
A Pixel 8a running Android 16 also retained the 1px image and its original src after editing, saving, and sending a separate Gmail API draft. The received raw MIME contained the exact edited text and original image URL. Opening the received message added one recorded fetch and one signature-verified webhook (6 → 7). Gmail app versions were not recorded on either device.
The preservation trials used one self-addressed email on one device per platform. In a subsequent test sent to a separate Gmail account, the tester opened the received message in Gmail for iOS: the fetch count increased from 0 to 1, with a matching signature-verified webhook accepted with HTTP 204. The recipient account's raw MIME was not inspected. Repeated recipient openings, replies, and format changes remain unverified. Google's MIME draft API does not guarantee preservation in every mobile editor. See the device record for the exact scope.
Pre-view requests were observed: four Google image proxy fetches and their signed webhooks arrived before the user opened the received message. They carried google_image_proxy, but could not be identified as self-opens through the proxy. A notification can therefore precede recipient viewing.
Opening the received message then added one recorded fetch and one signature-verified webhook (4 → 5), completing this Gmail draft-to-webhook trial. This single case does not guarantee a fetch on every reopening.
The Android draft produced three fetches and verified webhooks by the edit/save checkpoint, with no classification flags. After sending but before recipient viewing, there were six: five without flags and one with google_image_proxy. An empty flags list is not proof of recipient reading.
Before relying on your device:
- Create a fresh draft/pixel. Retrieve
users.drafts.get?format=raw, decode the MIME HTML, and confirm the exact URL. - Open, edit and save it in Gmail iOS/Android. Inspect the raw draft again. Record OS/Gmail versions.
- Send to a separate account you control. Inspect the recipient's original MIME (
users.messages.get?format=rawor Gmail web “Show original”) for the pixel. A composer screenshot is insufficient. - Note fetches before recipient viewing, then open on the recipient device and compare timestamps. Reopen to observe caching. Test sending without edits, replies, and format switches separately.
- Repeat per platform. If the image disappears, regenerate the HTML draft or send via the Gmail API; do not assume mobile edits are safe.
Use the compatibility record. CI sends no mail and makes no live mobile or MPP accuracy claim.
- No verified read or reader identity. Requests do not establish attention, duration, reader, or forwarding. Anyone with a pixel URL can create events by fetching it.
- Gmail proxy/cache. Google documents image proxying. Caches can hide repeats and reuse pre-send fetches. No-cache headers cannot force clients to refetch. Even one fetch is not proof of reading; no fetch does not prove unread.
- Apple background loading. Apple documents downloading remote content when received, instead of viewed. This can produce false opens. Server-only MPP detection here is unreliable, with false positives and negatives.
- Blocked images. Plain text, sanitizers, tracking blockers, and disabled images cause missing events. Security scanners cause extra events. Gmail can suppress suspicious images.
- Observed origin requests only. Timestamps are fetch times;
countryoften describes a proxy. User-agents are untrusted. Raw IPs are not saved. - Availability and abuse. Recording/configuration failures still return the GIF but lose that event. Generic errors are logged without metadata. Public pixels have no human challenge; a known URL can be abused to exhaust platform resources.
- Retention. No automatic expiry; storage grows with usage. One SQLite Durable Object per installation has platform limits. Export every page and DELETE messages when appropriate. Keep the Worker namespace/migration history on upgrades. No automatic import from the partial KV prototype.
- Privacy. Stored data includes optional recipient/subject, UA, country, timestamps, flags and delivery diagnostics. URLs contain random IDs only. Minimize metadata and use disclosure/consent suitable for your context. Never put API secrets in drafts, URLs or Git.
All /v1/* routes require Authorization: Bearer <API_TOKEN>. API responses use Cache-Control: no-store. No public metadata/history, permissive CORS, or query-token authentication.
| Method | Path | Description |
|---|---|---|
| POST | /v1/messages |
JSON: optional recipient, subject, external_id (string/null), armed (boolean; default true). Optional Idempotency-Key header. |
| GET | /v1/messages?limit=100&after=0 |
All messages, including never-fetched ones. Follow next_after. |
| GET | /v1/messages/:id |
Metadata, totals and pixel HTML. |
| GET | /v1/messages/:id/opens?limit=100&after=0 |
Complete timeline across pages with flags and webhook status. |
| POST | /v1/messages/:id/arm |
Arm once; preserve the initial arm time. |
| DELETE | /v1/messages/:id |
Delete metadata, history and queued jobs; pixel stops recording. |
| POST | /v1/self |
Set owner cookie, also available through the /self setup form. |
| GET | /p/:id.gif |
Public transparent GIF; only registered UUIDs record. Unknown IDs return the same image and allocate no history. |
| HEAD | /p/:id.gif |
Image headers without recording. |
Registration body: maximum 16 KiB; recipient/subject: 1,000 characters each; external ID: 256. Unknown fields are rejected. IDs are server-generated UUIDv4. Pagination: limit 1–500; after is the previous numeric next_after. external_id can link your workflow; Gmail draft message IDs change when drafts are replaced.
Personal settings belong in Cloudflare Worker environment bindings. cloudflare.config.ts (cf) and wrangler.toml (Wrangler) contain infrastructure only, without personal URLs, account IDs, emails or keys.
| Binding | Default | Purpose |
|---|---|---|
API_TOKEN |
required | Bearer token and owner-cookie signing key; 32+ characters. Rotation invalidates owner cookies. |
WEBHOOK_URL |
unset | HTTPS receiver; no credentials/fragments/redirects. Unset disables new jobs. |
WEBHOOK_SECRET |
required with URL | Separate 32+ character signing key. |
WEBHOOK_MODE |
every |
first / every. |
WEBHOOK_FILTER |
all |
all / unflagged. |
SELF_IPS |
unset | Comma-separated exact IPv4/IPv6 owner IPs; transient comparison only. |
SELF_WINDOW_SECONDS |
30 |
Early-fetch heuristic: 0–86400 seconds. |
PUBLIC_BASE_URL |
request origin | Optional fixed HTTPS origin for pixel URLs; no path/query. Useful with a custom domain. |
cp .dev.vars.example .dev.vars
# Replace the example API_TOKEN and configure optional local bindings.
# Choose one local dev server:
pnpm run dev:cf
# Or: pnpm run dev:wrangler
pnpm check
pnpm test
pnpm run build:wrangler
pnpm run build:cf
pnpm run check:cli-buildsLocal development uses local storage and does not deploy. Owner cookies require HTTPS; test /self on an HTTPS deployment. Email clients cannot fetch localhost. Local variables, dependencies and build output are ignored by Git.
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.
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. 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.
MIT · Copyright 2026 Arkor