A production-ready Gmail auto-reply bot that runs on Google Cloud Platform. It reads incoming emails, filters out noise (newsletters, marketing, automated messages), and uses Gemini Flash to decide whether a reply is needed — then sends one automatically.
Gmail Inbox
→ Gmail Watch API
→ Google Pub/Sub
→ Cloud Run (this service)
→ Gemini Flash (decision + reply generation)
→ Gmail API (send reply)
| Tool | Version | Install |
|---|---|---|
| Node.js | ≥ 20 | https://nodejs.org |
Google Cloud SDK (gcloud) |
latest | https://cloud.google.com/sdk/docs/install |
| Docker | latest | https://docs.docker.com/get-docker/ |
Run this once after creating/selecting your GCP project:
gcloud config set project YOUR_PROJECT_ID
gcloud services enable \
gmail.googleapis.com \
pubsub.googleapis.com \
run.googleapis.com \
firestore.googleapis.com \
secretmanager.googleapis.com \
cloudscheduler.googleapis.com \
cloudbuild.googleapis.com \
artifactregistry.googleapis.com- A Google Workspace account (or Gmail account) whose inbox you want to monitor
- A GCP project with billing enabled
- A Gemini API key — get one at https://aistudio.google.com/app/apikey
git clone <your-repo-url> gmail-ai-bot
cd gmail-ai-bot
npm installThis service account is what the bot uses to call Gmail, Firestore, and Secret Manager.
# Create the service account
gcloud iam service-accounts create gmail-ai-bot-sa \
--display-name="Gmail AI Bot Service Account"
# Grant Firestore access
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:gmail-ai-bot-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/datastore.user"
# Grant Secret Manager access
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:gmail-ai-bot-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
# Grant Cloud Run invoker (needed for Pub/Sub push auth)
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:gmail-ai-bot-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/run.invoker"
# Download the JSON key (keep this safe — never commit it)
gcloud iam service-accounts keys create ./gmail-sa-key.json \
--iam-account=gmail-ai-bot-sa@YOUR_PROJECT_ID.iam.gserviceaccount.comThe service account needs permission to read and send email on behalf of your Gmail account.
If you use Google Workspace:
- Go to Google Workspace Admin Console → Security → API Controls → Domain-wide Delegation
- Click Add new and enter:
- Client ID: the
client_idfrom yourgmail-sa-key.json - OAuth scopes:
https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.send,https://www.googleapis.com/auth/gmail.modify
- Client ID: the
If you use a personal Gmail account:
Personal Gmail does not support domain-wide delegation. Instead:
- Go to Google Cloud Console → APIs & Services → OAuth consent screen — configure it
- Go to Credentials → Create Credentials → OAuth 2.0 Client ID
- Use the OAuth flow to generate a refresh token for your account
- Store the full OAuth credentials JSON as
GMAIL_SERVICE_ACCOUNT_KEYin Secret Manager (same format, just different auth type)
# Gmail service account key
gcloud secrets create GMAIL_SERVICE_ACCOUNT_KEY --replication-policy="automatic"
gcloud secrets versions add GMAIL_SERVICE_ACCOUNT_KEY --data-file=./gmail-sa-key.json
# Gemini API key
echo -n "YOUR_GEMINI_API_KEY" | \
gcloud secrets versions add GEMINI_API_KEY --data-file=-
# (create the secret first if it doesn't exist)
gcloud secrets create GEMINI_API_KEY --replication-policy="automatic"
echo -n "YOUR_GEMINI_API_KEY" | \
gcloud secrets versions add GEMINI_API_KEY --data-file=-
# API auth key (used to protect the /api/v1/* config endpoints)
# Generate a strong random value:
openssl rand -hex 32 | \
gcloud secrets versions add API_AUTH_KEY --data-file=-
gcloud secrets create API_AUTH_KEY --replication-policy="automatic"
openssl rand -hex 32 > /tmp/api-key.txt
gcloud secrets versions add API_AUTH_KEY --data-file=/tmp/api-key.txt
rm /tmp/api-key.txt
# Tip: save that API key somewhere safe — you'll need it to call /api/v1/* endpointsAfter this step, delete
./gmail-sa-key.jsonfrom your local machine — it's now safely in Secret Manager.
# Create a Firestore database in Native mode (us-central1 recommended)
gcloud firestore databases create --location=us-central1Create the initial config document. You can do this in the Firestore Console or via CLI:
# Using the Firebase Admin SDK or just the console:
# Collection: system_config
# Document: email_rules
# Fields (all optional — defaults are in config/defaultConfig.js):
{
"active": false, ← set to true when ready to go live
"replyEnabled": true,
"blacklistedEmails": [],
"blacklistedDomains": [],
"whitelistedEmails": [],
"ignoreSubjectPatterns": [],
"ignoreBodyPatterns": [],
"replyOncePerThread": true,
"maxRepliesPerSenderPerHour": 2,
"minConfidence": 0.75,
"replySignature": "Sent by AI assistant",
"senderName": "Your Name"
}# Create the topic
gcloud pubsub topics create gmail-notifications
# Grant Gmail permission to publish to this topic
gcloud pubsub topics add-iam-policy-binding gmail-notifications \
--member="serviceAccount:gmail-api-push@system.gserviceaccount.com" \
--role="roles/pubsub.publisher"The push subscription is created after Cloud Run is deployed (you need the URL first — see step 8).
gcloud artifacts repositories create gmail-ai-bot \
--repository-format=docker \
--location=us-central1 \
--description="Gmail AI Bot container images"Update the substitution variables in cloudbuild.yaml to match your project, then run:
gcloud builds submit \
--config=cloudbuild.yaml \
--substitutions=\
_REGION=us-central1,\
_SERVICE_NAME=gmail-ai-bot,\
_AR_REPO=gmail-ai-bot,\
_GMAIL_USER_EMAIL=you@yourdomain.com,\
_PUBSUB_TOPIC=projects/YOUR_PROJECT_ID/topics/gmail-notificationsAfter deploy completes, get your Cloud Run service URL:
gcloud run services describe gmail-ai-bot \
--region=us-central1 \
--format="value(status.url)"
# Example output: https://gmail-ai-bot-abc123-uc.a.run.appReplace YOUR_CLOUD_RUN_URL with the URL from step 8.
# Create a service account for Pub/Sub to authenticate with Cloud Run
gcloud iam service-accounts create pubsub-invoker \
--display-name="Pub/Sub Cloud Run Invoker"
gcloud run services add-iam-policy-binding gmail-ai-bot \
--region=us-central1 \
--member="serviceAccount:pubsub-invoker@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/run.invoker"
# Create the push subscription
gcloud pubsub subscriptions create gmail-push-sub \
--topic=gmail-notifications \
--push-endpoint=YOUR_CLOUD_RUN_URL/webhook \
--push-auth-service-account=pubsub-invoker@YOUR_PROJECT_ID.iam.gserviceaccount.com \
--ack-deadline=60 \
--min-retry-delay=10s \
--max-retry-delay=300sGmail watch notifications expire after 7 days. This job renews them every 6 days.
# Get your API key from Secret Manager
gcloud secrets versions access latest --secret=API_AUTH_KEY
# Create the scheduler job
gcloud scheduler jobs create http gmail-watch-refresh \
--location=us-central1 \
--schedule="0 9 */6 * *" \
--uri="YOUR_CLOUD_RUN_URL/api/v1/gmail/refresh-watch" \
--http-method=POST \
--headers="Authorization=Bearer YOUR_API_KEY,Content-Type=application/json" \
--attempt-deadline=30s \
--description="Refreshes Gmail push notification watch every 6 days"The bot starts with active: false for safety. Once everything is deployed and tested, turn it on:
curl -X PATCH YOUR_CLOUD_RUN_URL/api/v1/config \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"active": true}'All endpoints require Authorization: Bearer YOUR_API_KEY.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/config |
Get full current config |
PATCH |
/api/v1/config |
Update any config fields |
GET |
/api/v1/status |
Health + config summary |
POST |
/api/v1/config/blacklist/email |
Add email to blacklist — body: {"email":"x@y.com"} |
DELETE |
/api/v1/config/blacklist/email/:email |
Remove email from blacklist |
POST |
/api/v1/config/blacklist/domain |
Add domain — body: {"domain":"example.com"} |
DELETE |
/api/v1/config/blacklist/domain/:domain |
Remove domain |
POST |
/api/v1/config/whitelist |
Add email to whitelist — body: {"email":"x@y.com"} |
DELETE |
/api/v1/config/whitelist/:email |
Remove from whitelist |
POST |
/api/v1/config/pattern/add |
Add filter pattern — body: {"field":"ignoreSubjectPatterns","pattern":"weekly"} |
POST |
/api/v1/config/pattern/remove |
Remove filter pattern |
POST |
/api/v1/gmail/refresh-watch |
Manually refresh Gmail watch |
# Disable the bot instantly (no redeploy needed)
curl -X PATCH YOUR_CLOUD_RUN_URL/api/v1/config \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"active": false}'
# Blacklist a sender
curl -X POST YOUR_CLOUD_RUN_URL/api/v1/config/blacklist/email \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "alerts@noisyservice.com"}'
# Block an entire domain
curl -X POST YOUR_CLOUD_RUN_URL/api/v1/config/blacklist/domain \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "marketing.com"}'
# Raise the Gemini confidence threshold (more conservative replies)
curl -X PATCH YOUR_CLOUD_RUN_URL/api/v1/config \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"minConfidence": 0.9}'cp .env.example .env
# Fill in your values — for local dev you can set the secret env vars directly in .env
npm run devTo simulate a Pub/Sub push notification locally:
# Encode a test payload
echo -n '{"emailAddress":"you@yourdomain.com","historyId":"12345"}' | base64
# Send it to the local webhook
curl -X POST http://localhost:8080/webhook \
-H "Content-Type: application/json" \
-d '{
"message": {
"data": "<base64-encoded-payload>",
"messageId": "test-001"
}
}'Note: locally the Pub/Sub JWT validation is skipped if
PUBSUB_AUDIENCEis not set.
| Symptom | Likely cause | Fix |
|---|---|---|
| No emails being processed | Bot is inactive | PATCH /config with {"active":true} |
historyId errors in logs |
Watch expired or historyId gap | Call POST /api/v1/gmail/refresh-watch |
| Gemini returning non-JSON | Model hallucination | Already handled — falls back to shouldReply: false |
| Duplicate replies | Firestore write failed after send | Check Firestore permissions; consider retrying write before send |
401 on /webhook |
Pub/Sub JWT not verified | Ensure PUBSUB_AUDIENCE matches your Cloud Run URL exactly |
| Secrets not loading | Missing Secret Manager roles | Re-check service account has roles/secretmanager.secretAccessor |
- Idempotency: The webhook returns HTTP 200 immediately. Processing is async. If it fails silently, Pub/Sub will not retry — this avoids duplicate-reply storms. Transient failures (Gemini 503) are retried internally with exponential backoff.
- Loop prevention: Four independent guards — self-send check,
Auto-Submittedheader,List-Idheader, and per-thread Firestore dedup. - Config hot-reload: Firestore config changes take effect within 60 seconds (the cache TTL) without any redeployment.
- Scale: Cloud Run scales to zero when idle (~50 emails/day = near-zero cost). Scales horizontally for bursts. Firestore handles concurrent writes safely.