Back to all projectsAI / Productivity

Flowmail

Human-in-the-loop AI

An AI email productivity platform on Cloud Run with Supabase, integrating Gmail via OAuth. A Gemini-powered feature suggests board organization and automated rules with a human-in-the-loop accept/reject workflow.

Next.jsNode.jsSupabaseGemini API

How it works

01·Gmail (OAuth)02·Ingestion03·Gemini Suggestion04·Human Review05·Board

The problem

Gmail works fine as a mailbox. It falls apart as a workflow tool once inbound email is also where work gets assigned, tracked, and followed up on: folders and labels can't tell you which message still needs an answer and which one is already handled. I built Flowmail to keep Gmail as the source of truth and add a board on top of it. A rules engine and a review-before-apply AI layer route messages into project-specific Kanban boards, and a workspace layer lets an owner bring teammates onto specific boards without handing over the whole inbox.

Architecture

  • I built the backend as a single Hono service on Node and TypeScript, with routes for auth, emails, projects, todos, waitlist, AI, workspaces, and invitations, plus an internal sync endpoint that pulls new mail.
  • I put Gmail access behind a GoogleProvider and a ProviderFactory instead of calling the Gmail API directly. It's a seam I added so a second mail provider wouldn't mean rewriting the ingestion path, even though Gmail is the only one I've implemented so far.
  • I kept a consistent repository/service split across boards, rules, AI suggestions, workspaces, and todos: repositories own data access, services own the business logic on top of it.
  • I run Supabase Postgres as the store and treat Row-Level Security as a real access boundary, not a formality. Tables holding encryption keys and OAuth exchange codes have RLS turned on with no policy at all, so only the service role can touch them.
  • The Next.js frontend deploys separately on Vercel and talks to the backend through its own API routes.
Flowmail system architecture

Design decisions

Provider abstraction for mail

I wrapped Gmail access in a GoogleProvider behind a ProviderFactory before I needed a second provider, because the ingestion path is the part I didn't want to have to touch again later.

Deterministic rule resolution

When more than one board's rules match the same email, the highest-priority board wins, with ties breaking on the lowest board ID. I wanted classification to be predictable to the person who wrote the rule, so I made it a hard requirement instead of leaving the outcome to whatever order the code happened to check things in.

AI suggestions reuse the human write path

The board and rule suggestion services never create anything directly. They produce a pending suggestion with the specific emails that justified it attached as evidence, and accepting one calls the same create-board or create-rule code a manual action would. I didn't want a second, less-audited write path just because a suggestion came from Gemini instead of a person.

Authorization failures look like 404s

I made board and workspace access checks return "not found" instead of "forbidden" when a user lacks access, so a failed authorization check can't be used to figure out which boards or workspaces exist in the first place.

Ownership is structurally singular

A workspace has exactly one owner, and I didn't build a promote or transfer-ownership path. That was a scope call I made when I shipped workspaces, not something I ran out of time for.

Team workspaces

  • Only the workspace owner connects Gmail. Invited teammates work the boards that result and never connect an inbox of their own.
  • Invite links name specific board IDs, not the whole workspace, and are locked to one target email. If someone accepts with a different signed-in account, I reject it and tell them which account it's expecting. Links expire after 7 days.
  • I went through every ownership check that used to be a hardcoded user-id foreign key and converted it into a membership check, split by whether the action needs the owner role or just membership.
  • An owner can revoke one member's access to a single project without touching the rest of their access, or revoke the invitation outright.

AI architecture

  • I use two Gemini call shapes: a freeform summary call for thread summaries and action items, and a structured JSON-mode call with a response schema for anything the app needs to parse. I still re-validate the parsed JSON myself, since Gemini only guarantees shape, not correctness.
  • Board and rule suggestions are built from up to 10 user-selected emails, reduced to sender domain, subject, and labels before they ever reach Gemini. Full email bodies don't get sent.
  • Every suggestion is stored as pending with the exact emails that justified it attached as evidence. Nothing gets created until a human accepts it.
  • Identical trigger emails and prompt version return the suggestion I already generated instead of calling Gemini again.
  • I put a daily AI usage quota per user in place, and I bypass it for anyone using their own Gemini key. That's their cost to spend, not a shared budget I need to protect.

Security

  • I use envelope encryption: a random 32-byte key per user, and separately per workspace, wrapped with AES-256-GCM under a master key that only ever lives in an environment variable, never in the database.
  • OAuth tokens and cached email fields like subject, sender, recipient, and snippet are encrypted at rest. The metadata the rules engine matches against lives in a separate, unencrypted table so classification never has to decrypt anything.
  • Tables holding encryption keys and OAuth exchange codes have Row-Level Security turned on with no policy defined at all. Not even the owning user can read them through a client. Only the service role can.
  • When I shipped workspaces, I moved board email content to a workspace-scoped key so a shared board doesn't require re-encrypting content per member. OAuth tokens and bring-your-own Gemini keys stay per-user, since only the workspace owner's Gmail is ever connected.

Constraints & tradeoffs

OAuth exchange codes broke under autoscaling

I'd cached the one-time OAuth handoff code in an in-memory Map. That held up fine on a single process and broke the moment Cloud Run scaled past one instance, because the code could get written on one instance and read on another. I moved it to a Postgres table and consumed it with an atomic delete-and-return query so it can only ever be used once.

Google's account chooser was silently skipped

I hadn't set a prompt parameter on the auth URL, so Google was reusing a cached browser session instead of showing the account picker. Users landed on a waitlist-rejection page with nothing telling them Google had even been involved. I fixed it by forcing the account chooser on every login, and forcing re-consent on reconnect.

Encryption keys were derivable, not random

My original scheme derived a user's encryption key from a hardcoded master-key literal in source plus their email. Anyone with repo access could derive any user's key. I replaced it with envelope encryption: a random key per user, generated once and wrapped under a master key that lives only in an environment variable.

Rate limiting is still per-process

Rate limiting is still in-memory and scoped to a single process, the same assumption that broke the OAuth exchange codes. It hasn't caused a real incident yet, but I've flagged it as the next thing to fix before it does.

Migrations aren't in git

I apply database migrations straight to the live Supabase project from the CLI, and they aren't tracked in version control or run through CI. It's a real gap. I haven't automated it yet.

Deployment

  • The backend runs on Google Cloud Run in asia-south1, built from a Docker image. I put the API's custom domain behind Firebase Hosting instead of a Cloud Run domain mapping, because Firebase only needs a DNS A record and Cloud Run's own mapping needs Search Console verification.
  • GitHub Actions runs typecheck and tests on every push to main, then builds, pushes, and deploys the image to Cloud Run with no manual approval gate. GCP auth goes through Workload Identity Federation, so there's no service-account key sitting in GitHub Secrets.
  • One gotcha I hit for real: adding a required env var doesn't update the live Cloud Run service, because the deploy step only swaps the image. The container crash-loops on boot with a generic "failed to listen on port" error instead of a clear missing-variable message. It happened when I added a new internal sync secret.
  • The frontend deploys separately on Vercel.

Current state

Flowmail is live and still gated behind an approved waitlist while it's in early access. Most of my recent work has gone into fixing rule-matching, sync, and login reliability under real usage rather than adding new surface area.