How to Build a Solo Founder Automation Stack for Inbox Triage, Lead Qualification, and Daily Revenue Digests
A developer's guide to wiring Gmail, Redis-backed Bull queues, and scheduled revenue workers into one maintainable system.
TL;DR: Wire a Node.js webhook receiver to Gmail push notifications, enqueue triage and qualification jobs into Redis-backed Bull queues with idempotent job IDs, and schedule a daily worker that aggregates revenue from your payment API into a Slack digest. This replaces manual inbox sorting, lead scoring, and daily reporting for one-person SaaS operators.
Architecture and Stack Boundaries
Your automation architecture should decouple ingestion from processing with Redis-backed queues and stateless workers, splitting the system into three bounded contexts: Inbox Triage, Lead Qualification, and Daily Revenue Digest. Email remains where a huge chunk of work and revenue gets done, so Inbox Triage consumes Gmail push notifications, classifies intent, and drafts replies. Lead Qualification enriches signups and scores them before routing to Slack or a CRM. Daily Revenue Digest runs on a schedule, queries your payment API, and posts a summary so you never manually compile numbers again.
Keep each worker idempotent and avoid shared mutable state; if a worker crashes mid-flight, Redis lets another instance pick up the job without corruption. Bounded contexts prevent logic from bleeding across concerns: the inbox worker never touches CRM writes, and the revenue worker never polls Gmail. Because the queue acts as the single source of truth, you can scale workers horizontally without worrying about duplicate drafts or double-scored leads. This structure ensures that a restart or redeploy never leaves the system in an inconsistent state. A common approach is to generate deterministic job IDs from the event attributes and process them in isolated, stateless handlers:
app.post('/gmail-webhook', async (req, res) => {
const { email, intent, draftReply } = req.body;
await inboxQueue.add(
{ email, intent, draftReply },
{ jobId: `${intent}-${email}-${req.headers['x-request-id']}` }
);
res.sendStatus(200);
});
leadQueue.process(async (job) => {
const { email } = job.data;
const enriched = await qualifyLead(email);
await routeToCRM(enriched);
});
digestQueue.add({}, {
repeat: { cron: '0 9 * * *' },
jobId: 'daily-revenue'
});
Idempotent Inbox Triage with Gmail Webhooks
Use Gmail API push notifications to POST incoming messages to an HTTPS endpoint, then deduplicate processing with a composite Bull job ID so webhook retries never spawn duplicate drafts.
Start by registering your domain and a specific endpoint in Google Cloud Console as a push receiver for a Gmail label or the entire inbox. Each notification hits your Express server with a minimal payload; you then fetch the full thread if needed and enqueue the triage task. Inside the route handler, extract the fields and enqueue the job with a deterministic jobId built from intent, email, and the request ID header. This makes the operation idempotent: if Gmail retries the delivery because of a network timeout, Bull rejects the duplicate because the job ID already exists.
app.post('/webhook', (req, res) => {
const { email, intent, startTime, endTime, draftReply } = req.body;
const requestId = req.headers['x-request-id'] || `${Date.now()}`;
triageQueue.add(
{ email, intent, startTime, endTime, draftReply },
{ jobId: `${intent}-${email}-${requestId}` }
);
res.sendStatus(202);
});
The worker picks up the job, calls an LLM to classify the thread intent, and either commits a draft reply or surfaces complex issues directly to you. A separate queue layer can apply label moves or archive commands back through the Gmail API once the draft is confirmed. Because the webhook returns 202 Accepted immediately, the HTTP timeout window never blocks the worker. Stacks that auto-resolve routine tickets while escalating only exceptions can handle 60–80% of volume without human intervention.
Idempotent Lead Qualification with Bull
Run lead qualification in a dedicated Bull queue with deterministic job IDs so enrichment work never blocks your webhook and duplicate runs collapse to a single job. Because lead qualification is CPU-bound and relies on slow enrichment APIs, the webhook should immediately return a 202 Accepted and offload scoring to an asynchronous worker backed by Redis.
app.post('/webhook', (req, res) => {
const { email, intent, startTime, endTime, draftReply } = req.body;
const requestId = req.headers['x-request-id'] || `${Date.now()}`;
if (intent === 'lead-qualify') {
leadQueue.add(
{ email, intent, startTime, endTime, draftReply },
{ jobId: `${intent}-${email}-${requestId}` }
);
}
res.sendStatus(202);
});
The worker pulls the job, calls your enrichment provider, applies scoring rules, and posts qualified leads to a Slack channel. Keeping the job ID deterministic—composed from intent, email, and the request ID—guarantees idempotency even if the webhook fires twice. The same automation layer can draft follow-up emails and calendar invites, cutting manual sales ops to near zero.
leadQueue.process(async (job) => {
const { email, draftReply } = job.data;
const profile = await enrichLead(email);
const score = applyScoringRules(profile);
if (score >= 70) {
await notifySlack({ email, score, profile });
if (draftReply) await draftFollowUpAndInvite(email);
}
return { score, qualified: score >= 70 };
});
Daily Revenue Digest Worker
A daily revenue worker is a stateless Node.js cron job that queries your payment API each morning and posts a formatted summary to Slack, replacing the manual spreadsheet review that consumes founder hours.
Schedule the worker with node-cron to run at 09:00 UTC. Derive the reporting window from the current UTC date rather than storing a mutable cursor. By computing start and end as midnight boundaries for the previous day, the job stays idempotent: reruns or restarts always target the same 24-hour window and produce an identical digest. Query your payment API for total revenue, new MRR, and churn within that window, then format a concise Slack message. Keep the worker stateless by injecting the Slack client and API wrapper as arguments instead of relying on module-level state.
// workers/revenue.js
const cron = require('node-cron');
module.exports = function startRevenueWorker(slack, fetchDailyRevenue) {
cron.schedule('0 9 * * *', async () => {
const end = new Date();
end.setUTCHours(0, 0, 0, 0);
const start = new Date(end);
start.setUTCDate(start.getUTCDate() - 1);
const { total, newMrr, churn } = await fetchDailyRevenue(start, end);
await slack.chat.postMessage({
channel: '#founder-digest',
text: `*Daily Revenue*\nTotal: $${total}\nNew MRR: $${newMrr}\nChurn: $${churn}`
});
});
};
This pattern removes the need to open dashboards or sheets to understand cash flow, giving the founder an immediate, automated pulse on business health.
Wiring It All Together and Onboarding Guardrails
Run a single Node process that mounts your webhook, instantiates Bull queues, and attaches worker modules so every inbound event flows through one router. A common approach is to validate x-request-id before composing the jobId, return HTTP 202 immediately to signal acceptance and prevent sender retries, emit structured JSON logs with the request context, and cap worker concurrency so downstream APIs do not throttle.
The consolidated server.js below keeps routing and job creation in one place. It destructures the payload inside the route handler, enforces idempotency with a consistent intent-email-requestId composite, and returns 202 before processing begins. Worker files are imported at the bottom so processors attach to the same queue instances, and the identical email variable is reused across both triage and lead queues to avoid undefined placeholders in the jobId. Redis acts as the durable backing store, so jobs survive deploys and restarts.
// server.js
const express = require('express');
const Queue = require('bull');
const app = express();
app.use(express.json());
const triageQueue = new Queue('inbox-triage', process.env.REDIS_URL);
const leadQueue = new Queue('lead-qualification', process.env.REDIS_URL);
app.post('/webhook', (req, res) => {
const { email, intent, startTime, endTime, draftReply } = req.body;
const requestId = req.headers['x-request-id'] || `${Date.now()}-${Math.random()}`;
if (intent === 'inbox-triage') {
triageQueue.add(
{ email, intent, startTime, endTime, draftReply },
{ jobId: `${intent}-${email}-${requestId}` }
);
}
if (intent === 'lead-qualify') {
leadQueue.add(
{ email, intent, startTime, endTime, draftReply },
{ jobId: `${intent}-${email}-${requestId}` }
);
}
res.sendStatus(202);
});
require('./workers/triage')(triageQueue);
require('./workers/lead')(leadQueue);
require('./workers/revenue')();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Webhook server on ${PORT}`));
Keep the webhook handler stateless and let Bull manage retries and backoff in the worker processes. Limiting concurrency to three jobs per worker is a typical safeguard against CRM or LLM rate limits. For observability, write one structured log line per request that includes the jobId, intent, and timestamp so you can trace duplicates across services.
FAQ
How much founder time does this actually save?
By automating triage, qualification, and daily reporting, you replace the repetitive operational work that consumes the majority of a solo founder's day. Teams using self-serve automation often reduce per-customer onboarding and support load from 4hr/customer to 30min/customer.
Do I need Redis even if my volume is low?
Bull requires Redis to persist job state and enforce idempotency. Even at low volume, Redis prevents duplicate webhooks from creating multiple CRM entries or draft replies. A common approach is to run Redis in Docker for local development and use a managed provider in production.
How do I handle Gmail OAuth renewals without manual intervention?
Store refresh tokens in a secrets manager. A common approach is to schedule a background refresh before expiry so push notifications remain active and your webhook continues to receive events.
What happens if the revenue API is down when the digest runs?
Add a circuit breaker or try/catch that skips the digest and alerts you via email instead of posting a partial or misleading report to Slack. Keep the worker stateless so the next cron run attempts the fetch again.
Can I add new intents without redeploying everything?
Version your payload schema inside the webhook handler. Treat unknown intents as no-ops by returning 202. Deploy the new worker module first, then start sending the new intent from Gmail or your upstream source.
References for further reading
Sources consulted while researching this guide, included so you can verify the details and go deeper. Listing them is not a claim that every line was independently fact-checked.
- AI Project Management Stack for Solopreneurs: 2026 Guide
- Automation Stack for Solo Founders: Tools That Replace a Team
- Best AI Tools for Solopreneurs in 2026: The Lean Stack
- The Complete Solo Founder Workflow Automation Guide
- Top 5 Sales Tools for Startup Founders in 2026
I packaged the setup above into a ready-to-use kit — **Solo Founder Brain: OpenClaw Skill + Deployment Pack* — for anyone who'd rather copy-paste than wire it from scratch: https://unfairhq.gumroad.com/l/uzmtclu.*
Top comments (0)