# Stay in sight > Stay in sight is an open-source (MIT) outbound email service that runs on your own Cloudflare account (Workers + D1). It sends cold outreach, newsletters and drip series from your Gmail / Google Workspace and Amazon SES mailboxes within per-mailbox limits, personalizes each email with AI, categorizes replies, posts them to webhooks, and exposes everything through a web app, a REST API and an MCP server with 64 tools. Source: https://github.com/yusufnb/stay-in-sight This is the complete guide: what it is, how to install it, and how every feature works. It is written for people and for AI agents setting it up on someone's behalf. ## What it is One Cloudflare Worker and one D1 (SQLite) database. A cron trigger runs every minute: it sends emails that are due, checks mailboxes for replies, advances drip series and retries webhooks. The app (UI), the REST API (`/api`) and the MCP endpoint (`/mcp`) are all served by the same Worker and protected by one passcode. - **Mailboxes:** Gmail / Google Workspace (OAuth, sends through the Gmail API) and Amazon SES (sends through the SES API, replies arrive through Cloudflare Email Routing). - **Sending is always scheduled.** Every email has a send time; empty means within a minute. Each mailbox has a daily limit and a minimum gap between sends that are always enforced. - **Your data stays yours.** Contacts, emails, replies, settings and AI costs live in your own D1 database. Typical uses: founder-led cold outreach, agency outreach across several mailboxes, product onboarding drips, newsletters to sign-ups, and AI agents that send and answer email. ## Requirements - A Cloudflare account. The free plan works at low volume; Workers Paid ($5/month) gives more CPU time and subrequests for steady per-minute work. - Node.js 18+ and npm on the machine you deploy from. - A Google account to send from (Gmail or Google Workspace) and a Google Cloud project for the OAuth client. - Optional: an OpenRouter API key (AI reply categories and AI personalization), a Google Cloud Pub/Sub topic (instant reply detection), an AWS account with SES (newsletter-style sending), a domain on Cloudflare DNS (for SES replies). Plan on 30–45 minutes for a first install. ## Install ### 1. Get the code ```bash git clone https://github.com/yusufnb/stay-in-sight cd stay-in-sight npm install npx wrangler login ``` ### 2. Create the database ```bash npx wrangler d1 create cms-outbound ``` Copy the `database_name` and `database_id` it prints into `wrangler.toml` under `[[d1_databases]]`. The binding must stay `DB`. Then create the tables: ```bash npm run db:migrate ``` ### 3. Deploy and set the public URL ```bash npm run deploy ``` It prints your URL, for example `https://cms-outbound..workers.dev`. Put it in `wrangler.toml` as `PUBLIC_URL` and deploy again. The public URL is used for the tracking pixel, unsubscribe links and the Google OAuth redirect, so set it before connecting mailboxes. If you leave it empty, the first URL you use to reach the app is remembered. A custom domain can be added under Workers & Pages → your Worker → Settings → Domains & Routes. ### 4. Set the passcode ```bash openssl rand -base64 24 # generate a strong passcode if you like npx wrangler secret put PASSCODE ``` The passcode signs you into the app (30-day cookie), and is the Bearer token for the API and MCP. It is the only setting that can't be changed from inside the app. Changing it signs everyone out. ### 5. Connect Google (Gmail) 1. In Google Cloud Console, create or pick a project and **enable the Gmail API**. 2. **OAuth consent screen:** - Only Google Workspace mailboxes in your own organization → choose **Internal** (no verification; refresh tokens don't expire). - Gmail.com mailboxes, or several domains → choose **External**, add each sending address under *Test users*, then click **Publish app**. While an app is in *Testing*, Google expires refresh tokens after 7 days. An unverified published app shows a “Google hasn't verified this app” warning you can click through for your own mailboxes. - Scopes used: `gmail.send`, `gmail.readonly`, `openid`, `email`, `profile`. 3. **Credentials → Create OAuth client ID → Web application.** Authorized redirect URI: `https:///oauth/google/callback` (the app shows the exact value on the **MCP & API** page). 4. Store the client, either as Worker secrets or by pasting both values on the app's **Settings** page: ```bash npx wrangler secret put GOOGLE_CLIENT_ID npx wrangler secret put GOOGLE_CLIENT_SECRET ``` 5. Open the app, sign in, go to **Mailboxes → Connect Google mailbox**, and approve. Set the mailbox's reply webhook URL (and optional signing secret) if you want replies posted to your system. ### 6. Optional: AI (OpenRouter) ```bash npx wrangler secret put OPENROUTER_API_KEY ``` Or paste the key on **Settings**. The default model is `OPENROUTER_MODEL` in `wrangler.toml`; any OpenRouter model id can be chosen in the app. Without a key, only literal STOP replies get a category and AI personalization is unavailable. ### 7. Optional: instant reply detection (Gmail push) Without this, replies are found by polling Gmail history every minute, which is enough for most use. 1. Create a Pub/Sub topic, e.g. `projects//topics/gmail-outbound`, and grant `gmail-api-push@system.gserviceaccount.com` the **Pub/Sub Publisher** role on it. 2. Create a **push** subscription whose endpoint is the URL shown under **Mailboxes → Reply detection** (`https:///webhooks/gmail?token=…`). 3. Set `GMAIL_PUBSUB_TOPIC` in `wrangler.toml` and redeploy, or save the topic on **Settings** (it sets up push on every connected mailbox straight away). Watches are renewed automatically before their 7-day expiry. ### 8. Check it end to end 1. **Send → Single email** to an address you control in another account, time left empty. It goes out within a minute; watch it on **Emails**. 2. Reply with “Sounds good, can we talk Tuesday?”. It appears under **Replies**, categorized, and your webhook shows *delivered*. 3. Reply **STOP**. The address appears under **Do-not-send**. 4. Connect MCP (exact command on the **MCP & API** page) and ask your agent for an overview. ### One-command deploys (tenants) Each deployment can be one file, `tenants/.env`, holding its Worker name, database, settings and secrets: ```bash cp tenants/example.env tenants/acme.env # fill it in npm run deploy:tenant -- acme --dry-run # check, changes nothing npm run deploy:tenant -- acme ``` That command finds or creates the tenant's D1 database, writes a Wrangler config based on `wrangler.toml`, applies migrations, deploys the Worker, uploads the secrets in the file, sets `PUBLIC_URL` on a first deploy, and prints the Google redirect URI, MCP URL and Pub/Sub push endpoint. Tenant files are gitignored. One Google OAuth client can serve several tenants: add each tenant's redirect URI to it. ### Settings you can change in the app After the first deploy, everything except the passcode can be set on the **Settings** page instead of with `wrangler secret put` or a redeploy: Google client ID and secret, OpenRouter key, Gmail Pub/Sub topic, emails per minute, public URL and app name. A value saved in the app overrides the deploy-time value; **Clear** falls back to it. Secrets are write-only. App-saved values live in D1; Worker secrets are hidden even from the D1 console, so choose per value. ### Local development ```bash cp .dev.vars.example .dev.vars # set PASSCODE (and Google credentials to test OAuth) npm run db:migrate:local npm run dev # http://localhost:8787 curl "localhost:8787/__scheduled" # run the cron once npm test # unit and flow tests with mocked Gmail npm run typecheck ``` For OAuth locally, add `http://localhost:8787/oauth/google/callback` as a redirect URI on the Google client. Stop the dev server before running local migrations. ### Updating ```bash git pull npm run db:migrate # applies any new files in migrations/ npm run deploy # or: npm run deploy:tenant -- npx wrangler tail # live logs ``` ## Mailboxes A mailbox is a Gmail / Workspace account connected with OAuth, or an SES identity. Each one has: - **Daily limit** (1–2,000, default 50), counted over any rolling 24 hours, and a **throttle** (minimum minutes between sends, 1–1,440, default 5). Both always apply, however an email was scheduled. An email over a limit stays scheduled with a `waiting: …` reason and goes out at the next free slot. - **Purpose**: cold outreach, newsletter or conversation (below). - **Senders**: identities it can send as. - A **reply webhook URL** and optional signing secret. Gmail caps sending at about 500 a day for gmail.com and 2,000 for Workspace; keep limits well under that. A mailbox whose Google connection was revoked or expired shows **Reconnect**. ### Mailbox purposes | | Cold outreach | Newsletter | Conversation | |---|---|---|---| | For | One-to-one emails to people who haven't heard from you | Bulk mail to people who signed up | Back-and-forth with people who wrote to you | | Tracking pixel | Off | On | Off | | `List-Unsubscribe` + one-click | Off | On | Off | | Footer | `If you'd rather not hear from me, just reply "stop".` | `Unsubscribe: {{unsubscribe_url}}` | None | | `List-Id`, `Precedence: bulk` | No | Yes | No | | Auto-send spacing | Throttle plus up to 60% more, uneven | Exactly the throttle | Exactly the throttle | | Blocked by do-not-send scopes | all, marketing | any | all | | A STOP or unsubscribe adds scope | marketing | newsletter | all | | Suggested limits | 30/day, 1 every 8 min | 1,000/day, 1 every 1 min | 200/day, 1 every 1 min | New Gmail mailboxes start as cold outreach, SES mailboxes as newsletters. Tracking, the unsubscribe header and the footer can be overridden per mailbox. For commercial email, put your postal address in the footer. Mail is built when it's sent, so changes apply to emails already queued. ### Warm-up Turn it on in a mailbox's **Sending** tab (or `PATCH /api/mailboxes/:id {"warmup": true, "warmup_start": 5, "warmup_step": 3}`). Day 1 allows `warmup_start` emails, each day adds `warmup_step`, until the daily limit. With 5 and 3 and a limit of 30: 5, 8, 11, … and 30 a day from day 10. Auto-send plans around the growing limit. Warm-up paces volume only; it doesn't send or answer warm-up emails, so use an engagement warm-up service as well if you want one. ### Mailbox groups Several mailboxes that send as one, mainly for cold outreach. Create one under **Mailboxes → Mailbox groups** (or `POST /api/groups {"name": "Outreach team", "mailboxes": [1, 2, 3]}`). Send through it by choosing the group as the sender, or `"group": ""` in the API and MCP. - Each email goes to the member with the earliest free slot by its own throttle, daily limit and warm-up. Paused or disconnected members are skipped. - Each email goes out as that mailbox's default sender (`from_name` and `reply_to` can be overridden). - Group sends are always auto-planned; `send_at` / `start_at` is the earliest time. - Someone who already has email from a member stays with that member, so follow-ups come from the same address. ### Senders A sender is an identity: From name, From address, Reply-To, a label, a `key` for the API and MCP, and a **description of when to use it** that people and agents read. Every mailbox has a default sender. For Gmail the From address must be the mailbox or a verified “Send mail as” alias; for SES any address on the verified domain. Values are copied onto each email when scheduled. ### Amazon SES mailboxes SES has no inbox, so each SES email gets a reply address like `reply+@reply.example.com`, which Cloudflare Email Routing delivers to the Worker; bounces and complaints arrive through SNS. Your domain's DNS must be on Cloudflare. 1. **Verify your domain in SES** (Verified identities → Domain, Easy DKIM on). Add the three DKIM CNAMEs in Cloudflare DNS as *DNS only*. 2. **Request production access** to leave the SES sandbox. 3. **Create a send-only IAM user** with `ses:SendEmail`, `ses:SendRawEmail` on your identity and configuration set, plus `ses:GetAccount`, `ses:GetEmailIdentity`. Create an access key. 4. **Route replies:** Cloudflare → your domain → Email → Email Routing → add subdomain `reply` → Catch-all → *Send to a Worker* → your Worker. 5. **Bounces and complaints:** create an SNS topic, an HTTPS subscription to the URL under **Settings → AWS SES events (SNS)** (raw delivery off; it confirms itself), and an SES configuration set with an SNS destination for hard bounces, complaints and rejects. 6. **Add the mailbox:** **Mailboxes → Add AWS SES mailbox** with From address, region, keys, configuration set, reply domain and an optional forward-to inbox. 7. **Test:** send to `bounce@simulator.amazonses.com`; it shows *Bounced* and lands on do-not-send. ## Contacts and lists Import a CSV on **Contacts** (an `email` or `to` column is required; `name` optional; every other column becomes a field), or add contacts through the API: ```bash curl -X POST https:///api/contacts \ -H "Authorization: Bearer " -H "content-type: application/json" \ -d '{"email":"jane@acme.com","first_name":"Jane","company":"Acme","plan":"pro","lists":["Q1 Leads"]}' ``` Field keys are lowercased with spaces turned into `_`, and every field is a `{{variable}}`. Posting the same email again updates the contact; blanks never erase values. A contact can be on any number of lists. ### Filtering, sorting and bulk actions On **Contacts**, sort by any column or field and add conditions (match all or any). Quick filters: never emailed, not emailed in 30 days, opened but never replied, replied, on do-not-send, added in the last 7 days. Tick the header box, then **Select all N matching** to act on everyone across pages: add to list, remove from list, export CSV, delete. | field | ops | |---|---| | `email`, `name`, `field:` | `is`, `is_not`, `contains`, `not_contains`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `gt`, `lt` | | `sent_count` | `is`, `gt`, `lt` | | `created_at`, `last_sent_at`, `last_reply_at` | `within_days`, `older_than_days`, `before`, `after`, `is_empty`, `is_not_empty` | | `opened`, `replied`, `do_not_send` | `is` true / false | | `list`, `series` | `in`, `not_in` | The API takes the same `rules`, and bulk actions take `{"filter": {...}, "exclude_ids": [...]}` instead of ids. ## Templates A template has a permanent `template_id` (e.g. `intro-v1`), a subject and a body with `{{variables}}`. **A template reaches each address at most once**: anyone who has it scheduled, sending, sent or bounced is skipped, enforced by a unique index. Cancelled, failed and skipped emails don't count. Re-send a template to a growing list and only new people get it. A required variable that's blank skips the contact (unless AI personalization is on). Values inserted into HTML are escaped. ## Sending ### Single emails and the API ```json POST /api/emails { "sender": "sam", "to": "jane@acme.com", "subject": "Quick question", "body": "Hi Jane…", "send_at": "2026-11-12T09:30:00-05:00", "campaign": "q4-founders", "metadata": { "crm_id": "123" } } ``` `body` is HTML if it contains tags. `send_at` is ISO 8601 or epoch; omit to send on the next minute. `metadata` is echoed in reply webhooks. Send up to 1,000 at once with `{"emails": [...], "auto_schedule": true}`; use `template_id` and `vars` instead of subject and body. ### Auto-send With `auto_schedule`, each email gets the earliest time its mailbox allows, in order: at least the throttle after any other send, and no more than the daily limit in any rolling 24 hours, counting what's already queued. Existing scheduled emails keep their times. Preview with `GET /api/mailboxes/:id/plan?count=N` or `GET /api/groups/:id/plan?count=N`. ### Sending to lists On **Send → Send to lists**, pick lists, a sender or group, and a template or message. **Filter, order and limit** narrows it: - **Conditions** (same as Contacts): only matching contacts get it. - **Send order**: the order emails are scheduled in — the order contacts joined the list by default, or name, date added, emails sent, last emailed, or any field (numbers sort by value). - **Only send to the first N**: counts only people who can get it. With a template, sending again reaches the next N — an easy way to send a big list in daily batches. The preview shows how many match, how many will get it, who's skipped and why (do-not-send, already received, missing variable), how many the limit holds back, the first few names in order, and the day-by-day plan. ```json POST /api/lists/send { "lists": ["Users"], "sender": "me", "template_id": "upgrade-offer", "auto_schedule": true, "filter": { "rules": [ {"field":"field:plan","op":"is","value":"pro"}, {"field":"last_sent_at","op":"older_than_days","value":30} ] }, "sort": "field:score", "dir": "desc", "limit": 200 } ``` Up to 5,000 per send. `POST /api/lists/preview` takes the same body and sends nothing. ### Managing scheduled emails On **Emails**, filter by status, campaign, mailbox, purpose, group, template or engagement. Tick the header box and **Select all N scheduled** to cancel or send now across pages. Send now sends the first 25 straight away (skipping the throttle, not the daily limit) and makes the rest due for the next runs. Unsent emails can be edited or rescheduled one by one; editing a cancelled or failed email re-queues it. ## AI personalization Turn on **Personalize each email with AI** in a template and write instructions in plain words. Just before each email from that template is sent — from any path: Send page, list send, series, API, MCP — a model rewrites it for that person. - **It sees** the email as written for them, every contact field as it is at that moment (keep fields like `last_login` or `plan` current through the API), their lists, engagement (emails sent, last emailed, opened, replied), the email's metadata and today's date. - **It can** change subject and body as instructed while keeping tone, links and HTML; fill a missing `{{variable}}`; or **skip** with a reason (marked `AI: logged in today`). An email is never sent with a placeholder left in. - **If it fails**: *retry* (5 minutes later, up to 3 times, then failed) or *send as written*. - **Test with a contact** in the template editor: written vs personalized side by side, with the model, cost and data it saw. Works on unsaved instructions. API `POST /api/templates/personalize`; MCP `preview_personalization`. - **System prompt**: a default frames every call; a template can replace it. The skip rule, JSON answer format and date are always appended. - **Cost**: one call per email on the app's model or the template's own. Each email records status, model and cost. ```json PATCH /api/templates/checkin-v1 { "ai_enabled": true, "ai_on_error": "retry", "ai_prompt": "If first_name is missing, work it out from the email address. If last_login is within 7 days, mention what they did recently; if it's over 30 days ago, invite them back. If they logged in today, skip." } ``` ## Series (drip campaigns) An ordered set of template emails sent one after another to everyone on the series' lists. - **Steps** are templates (write one in the step, or pick a saved one). Contact fields fill the variables. - **Delays**: step 1 goes `delay_days` after someone joins (0 = right away); each later step `delay_days` after the previous email actually went out. - **Joining later**: anyone added to an attached list later is enrolled at step 1. Attach a list with `"only_new": true` to skip people already on it. Your app enrolls people by adding them to the list. - **Adding steps later** reaches everyone who hasn't had them, including people who had finished. - **Stopping**: do-not-send and hard bounces stop a person; optionally a reply does too (`stop_on_reply`). Pause a whole series with `active: false`. - Emails carry `series_id`, `series_step_id` and series metadata for webhooks. ```json POST /api/series { "name": "Onboarding", "sender": "product", "lists": ["New users"], "steps": [ { "subject": "Welcome, {{first_name}}", "body": "…", "delay_days": 0 }, { "subject": "Capture leads with forms", "body": "…", "delay_days": 2 } ] } ``` ## Replies Replies are detected by Gmail history polling every minute or instantly with Gmail push (SES replies arrive through Email Routing). Auto-replies and bounces are filtered out, quoted history is stripped, and the thread becomes a `conversation`. - **AI categories**: define categories by name and description on **Reply categories** (e.g. interested, not now, referral). Fill in **About your outreach** so replies are read in context. One short call per reply; the cost is stored. `stop` always exists. - **STOP**: `stop`, `unsubscribe`, `remove me`, `opt out` on the first line, or a `stop` category, puts the replier and the original recipient on do-not-send and skips their pending emails. - **Threaded replies**: answer any sent email in the same thread from its page, `POST /api/emails/:id/reply {"body": "…"}`, or MCP `reply_to_email`. ### Reply webhook For every human reply, the Worker POSTs to the mailbox's webhook URL with headers `x-outbound-event: reply | stop`, `x-outbound-category`, and `x-outbound-signature: sha256=`: ```json { "event": "reply.received", "mailbox": "me@company.com", "campaign": "q4-founders", "email": { "id": 12, "to": "jane@acme.com", "subject": "Quick question", "metadata": { "crm_id": "123" } }, "reply": { "id": 7, "from": "jane@acme.com", "text": "Sounds good, Tuesday works.", "is_stop": false, "category": { "name": "interested", "description": "…" }, "category_source": "ai" }, "thread_id": "18c…", "conversation": [ { "role": "us", "text": "Hi Jane…" }, { "role": "them", "text": "Sounds good, Tuesday works." } ] } ``` Non-2xx responses are retried with backoff (up to 6 attempts); redeliver by hand from the app. ## Do-not-send Entries come from STOP replies, unsubscribe links, hard bounces, spam complaints, and manual adds. Each has a scope: `all` (no mailbox emails them), `marketing` (no outreach or newsletters; conversation mailboxes can still answer) or `newsletter`. An address keeps its widest scope. Pending emails to a blocked address are skipped. ## Person timeline and campaigns Open any address for its timeline: every email from any mailbox, sender, list send, series or group (with opens), replies, list joins, series progress and do-not-send changes. `GET /api/people/:email/activity`; MCP `get_activity`. **Campaigns** shows sent, opened, replied and STOP rates per campaign name with a per-day breakdown. ## Authentication | Where | How | |---|---| | App | `/login` with the passcode → 30-day signed cookie | | REST API | `Authorization: Bearer ` (or `x-passcode` header) | | MCP | `Authorization: Bearer `, or `?key=` for URL-only clients | Public without the passcode: the tracking pixel `/t/.gif`, unsubscribe `/u/`, the OAuth callback (signed state), and the Gmail push endpoint (its own token, regenerable on Settings). ## MCP Endpoint: `https:///mcp` (streamable HTTP, stateless JSON). ```bash claude mcp add --transport http outbound https:///mcp --header "Authorization: Bearer " ``` 64 tools: `overview`, `get_settings`, `update_settings`, `list_mailboxes`, `update_mailbox`, `add_ses_mailbox`, `check_ses_mailbox`, `refresh_send_as`, `list_senders`, `save_sender`, `delete_sender`, `list_groups`, `save_group`, `delete_group`, `send_email`, `reply_to_email`, `schedule_emails`, `preview_schedule`, `list_emails`, `get_email`, `update_email`, `cancel_emails`, `send_now`, `get_conversation`, `list_templates`, `get_template`, `save_template`, `delete_template`, `check_template_recipients`, `preview_personalization`, `list_lists`, `save_list`, `delete_list`, `list_contacts`, `save_contact`, `get_contact`, `update_contact`, `delete_contacts`, `import_contacts`, `set_contact_lists`, `preview_list_send`, `send_to_lists`, `list_series`, `save_series`, `save_series_step`, `delete_series_step`, `delete_series`, `list_series_enrollments`, `set_series_enrollment`, `get_activity`, `list_replies`, `check_replies`, `redeliver_webhook`, `list_categories`, `save_category`, `delete_category`, `set_categorizer`, `set_reply_category`, `recategorize_reply`, `list_campaigns`, `campaign_stats`, `dnc_list`, `dnc_add`, `dnc_remove`. Everything in the app can be done over MCP except disconnecting a mailbox and regenerating the Gmail push token, which stay in the UI on purpose. ## REST API Interactive docs (Swagger UI) are at `/api-docs` on your deployment and the OpenAPI 3 spec at `/api-docs/openapi.json` (signed in, or with the Bearer passcode). All routes are under `/api`, JSON in and out. | Area | Routes | |---|---| | Overview and settings | `GET /overview`, `GET /config`, `GET/PUT /settings`, `POST /run` | | Mailboxes | `GET /mailboxes`, `PATCH/DELETE /mailboxes/:id`, `POST /mailboxes/:id/sync`, `POST /mailboxes/:id/send-as`, `GET /mailboxes/:id/plan` | | Groups and senders | `GET/POST /groups`, `GET/PATCH/DELETE /groups/:id`, `GET /groups/:id/plan`, `GET/POST /senders`, `GET/PATCH/DELETE /senders/:id` | | Emails | `GET/POST /emails`, `GET/PATCH /emails/:id`, `GET /emails/:id/conversation`, `POST /emails/:id/reply`, `POST /emails/cancel`, `POST /emails/send-now` | | Templates | `GET/POST /templates`, `GET/PATCH/DELETE /templates/:id`, `POST /templates/:id/check`, `POST /templates/personalize` | | Contacts and lists | `GET/POST /contacts`, `GET/PATCH /contacts/:id`, `POST /contacts/import`, `POST /contacts/lists`, `POST /contacts/delete`, `GET /contacts/fields`, `GET /contacts/export`, `GET/POST /lists`, `PATCH/DELETE /lists/:id`, `POST /lists/preview`, `POST /lists/send` | | Series | `GET/POST /series`, `GET/PATCH/DELETE /series/:id`, `POST /series/:id/steps`, `PATCH/DELETE /series/:id/steps/:step`, `GET /series/:id/enrollments`, `PATCH /series/:id/enrollments/:id`, `POST /series/:id/run` | | Replies and AI | `GET /replies`, `POST /replies/:id/redeliver`, `POST /replies/:id/category`, `POST /replies/:id/categorize`, `GET/POST /categories`, `DELETE /categories/:name`, `GET/PUT /ai`, `POST /ai/test` | | People, campaigns, do-not-send | `GET /people/:email/activity`, `GET /campaigns`, `GET /campaigns/:name`, `GET/POST/DELETE /dnc` | ## Behaviour notes - **Sending runs** every minute, up to `SEND_BATCH` (default 20) emails per run across mailboxes. Failed sends retry twice, 5 minutes apart; auth failures flag the mailbox for reconnect. - **Opens are approximate**: Apple Mail Privacy Protection and Gmail's image proxy can fetch the pixel without a human, and clients that block images never register. - **Replies** are matched by Gmail thread, so only replies to emails sent by Stay in sight are picked up. If polling is down for more than about a week, Gmail's history cursor expires and that gap is missed (the mailbox warns you). - **Cloudflare plan**: free works at low volume; Workers Paid helps once several mailboxes send and sync every minute. ## Troubleshooting - **“Auth failed, reconnect mailbox”**: the Google consent screen is probably still in *Testing* (tokens expire after 7 days). Publish the app, then click **Reconnect**. - **Emails stay scheduled with `waiting: …`**: a daily limit, throttle or warm-up limit is holding them; they go at the next free slot. Check the mailbox's Sending tab. - **Contacts skipped with “missing value for …”**: the template needs a field those contacts don't have. Fill the field, or turn on AI personalization to fill it. - **“already received this template”**: by design; a template reaches an address once. Use a new template id to send again. - **No replies showing**: replies are only matched to emails sent by this app, and arrive within a minute (seconds with Gmail push). Click **Run now** to check immediately. - **Logs**: `npx wrangler tail`. ## How it compares Hosted cold-email tools (lemlist, Instantly, Smartlead) are quicker to start and include lead databases, engagement warm-up networks and multi-seat teams. Stay in sight is self-hosted and MIT licensed: your data sits in your own Cloudflare D1 database, there are no per-seat or per-contact fees, newsletters and conversations live alongside cold outreach, and agents get a 64-tool MCP server and a full REST API. See https://stayinsight.sleeksite.ai/compare for the detailed comparison. ## Links - Source and issues: https://github.com/yusufnb/stay-in-sight - License: MIT - Full README and DEPLOY guide in the repository