I send a lot of proposals. Probably too many. And for the longest time my system for tracking them was opening Gmail and scrolling. Did they reply? Did I already follow up? Was that the one from Tuesday or Thursday?
I kept losing track of stuff, and losing money along with it.
The gap nobody was filling
I tried the usual tools. Mailtrack gives you open notifications, so you know someone saw your email, but you're still mentally sorting through which proposals need follow-up. Streak and HubSpot go the other direction with full CRMs, pipelines, deal stages, contact management. I don't need to manage a sales pipeline. I need to know who ghosted me.
None of them answered the one question I actually had: which of my sent emails need attention right now?
So I built Pynglo. It connects to your Gmail, watches your sent emails, and sorts them into four buckets: Fresh, Waiting, Ghosted, and Replied. One dashboard, one glance. If someone's ghosting you, you know. If they opened your follow-up three times but haven't replied, you know that too.
Here are the parts that were genuinely hard to build. If you're thinking about building anything on top of Gmail, you should know what you're signing up for.
Google OAuth
The Gmail API is powerful. Getting access to it is miserable.
You need three scopes: gmail.readonly to read sent mail, gmail.send for follow-ups, and gmail.modify for thread management. That part's fine. The trouble starts with refresh tokens.
Google gives you an access token that expires in an hour. To keep working after that, you need a refresh token. But Google only hands you one if you set access_type: "offline". And even then, only on the first authorization. If a user revokes access and comes back, you don't get a new refresh token. Your app just stops working and you don't know why.
The fix is forcing the consent screen every time with prompt: "consent". Without it, everything works in development, passes all your tests, and breaks for the first real user who re-authorizes. I lost a few hours to this one.
There's also a serverless problem. Two functions can try to refresh the same token at the same time. Both call Google, both get new tokens, but Google might invalidate the first one. I kept seeing tokens randomly die in production before I added a guard: re-read the token from the database before refreshing. If the expiry moved forward, another instance already handled it.
The serverless race condition
Free plan gets 10 emails per month. Check the count, reject if over.
Except on Vercel, two sync requests can hit different instances at the same time. Both read "9 tracked." Both say cool, under the limit. Both increment. Now you're at 11.
This only showed up under real load. The fix was moving the check-and-increment into a single Postgres function with FOR UPDATE, which is a row-level lock that makes the second request wait until the first finishes. Atomic operation, no double-counting.
I ended up using the same pattern for rate limiting, webhook deduplication, and anywhere else I had check-then-act logic. Serverless and non-atomic operations don't mix.
Syncing 500 emails without getting rate-limited
On first sign-up, I pull up to 500 sent emails so the dashboard isn't empty. Gmail's API gives you message IDs in batches, then you fetch each one individually for headers. That can be 500 individual API calls.
My first version just fired them off as fast as possible. Worked on my test account with 30 emails. First real user with a busy inbox hit 429s everywhere.
I added a 50ms sleep between calls. That took syncs from crashing after 80 emails to reliably processing 500. I kept looking for a smarter solution for a while before accepting that the sleep was the solution.
For subsequent syncs, I only fetch the latest batch. And once I set up Gmail's Pub/Sub push notifications, syncs became event-driven, but that was a later migration.
Reply detection through threading
The obvious approach to detecting replies: for each tracked email, search Gmail for messages from that recipient. That's O(n) API calls where n is your total tracked emails.
Gmail threads make this way better. Emails in the same conversation share a thread_id. I group all unreplied tracked emails by thread, fetch each thread once, and check if any message in the thread was sent by the recipient. 200 tracked emails might only be 30 or 40 threads, so most of the API calls just go away.
One thing that helped: request format: "metadata" with only the From header. You don't need message bodies for reply detection, and the smaller payloads add up.
The tracking pixel
Open tracking works by embedding a 1x1 transparent GIF in outgoing emails. When the recipient's email client loads the image, your server logs it. Simple enough in theory. Three things got me:
First, cache headers. Without Cache-Control: no-store, no-cache, must-revalidate, the email client or a proxy caches the GIF after the first load. Second open gets served from cache. Your server never sees it. I spent longer than I'd like to admit wondering why open counts were stuck at 1.
Second, always return the GIF. The pixel lives inside someone's email. If your database is down and you return a 500, some email clients show a broken image icon in your recipient's inbox. Return the GIF no matter what, log asynchronously.
Third, rate-limit the endpoint. Corporate email security scanners hit tracking pixels on behalf of users, sometimes dozens of times. Without rate limiting, one email to a company with aggressive pre-fetching shows 40 opens. Took me a while to figure out why the data looked so wrong.
Unusual silence detection
This is probably the most interesting part of the product. Basic ghosting detection is just a timer: no reply after 5 days, mark it ghosted. But some clients reply in 2 hours. Some take a week. Five days means very different things.
I track per-contact response patterns. After 3 or more emails to the same person, I have a baseline average response time. If your client usually replies in 4 hours and it's been 12, I flag that, even though the 5-day threshold hasn't triggered. If someone always takes a week, I don't raise an alarm on day 3.
There's also a "hot lead" signal: emails opened 3 or more times in the last 24 hours without a reply. That's someone who keeps looking at your proposal but hasn't committed. Good time to send a follow-up.
Mistakes
I started with polling instead of push notifications. Gmail supports Pub/Sub, where Google pushes to your webhook when something changes. I skipped it because it requires a Google Cloud project with Pub/Sub configured, and I wanted to ship. Migrating later meant reworking the sync flow. I should have set it up from the start.
I stored OAuth tokens in my main database. They're encrypted with AES-256-GCM, and it works, but a dedicated secrets manager would've been architecturally cleaner.
I didn't rate-limit the tracking pixel at first. Corporate scanners were inflating open counts and it took me a while to figure out why the numbers didn't make sense.
The stack
Next.js 15 on Vercel. Supabase for Postgres. Gmail API for reading and sending. Resend for transactional emails. Lemon Squeezy for payments. Cloudflare for DNS. Five services total, zero infrastructure to manage.
If you're building on the Gmail API, get one real user before you optimize anything. My test account had 47 sent emails. The first real user had 3,000. Every assumption I'd made about performance and rate limits turned out to be wrong.
Pynglo is live at pynglo.com if you want to try it. Free tier, no credit card.
Top comments (0)