Iurii RoguliaIurii Rogulia
AboutAI EngineeringServicesProjectsAI SkillsStackTestimonialsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a numberThink of a birthday
Hourly ratesChecklistsPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

  1. Home
  2. /
  3. Projects
  4. /
  5. Agentic AI CRM – Inbound Email Analysis and Lead Scoring on Google Cloud

Iurii Builds: Agentic AI CRM – Inbound Email Analysis and Lead Scoring on Google Cloud

October 27, 2026

Three Gemini agents on Google Cloud research the sender, read the intent and score each inbound email. One trusted service writes the CRM data.

Stack

PythonFastAPI

Libraries

PydanticHTMX

Databases

BigQueryFirestore

Services

Cloud RunCloud WorkflowsEventarcGoogle CloudPub/SubResendVertex AI

Topics

Agentic AIAI AgentsCRMData PipelineGeminiLLMWebhooks

Key Results

  • Each inbound email becomes a scored lead with a priority, a next step and a draft reply, without anyone reading it first
  • 23 of 24 labelled test emails get the right intent; the priority cap holds on all 24
  • A duplicate delivery, a retry or a failed agent never creates a second contact or erases earlier data
  • 73 automated tests and an evaluation harness that runs against the live model
Agentic AI CRM – Inbound Email Analysis and Lead Scoring on Google Cloud

The Challenge

A shared inbox is where B2B interest gets lost. Someone writes asking for a price, someone else offers a partnership, a third message is spam with a polite subject line. All of them look the same until a person opens them, and by then the one that mattered has waited behind five that did not.

The usual fix is a CRM, but a CRM only stores what somebody types into it. I wanted the opposite order: the message arrives, the system works out who wrote, what they want and what the message is worth, and a person sees the result and decides what to do. The agents propose, a human acts. Nothing in the system sends a reply.

The Solution

The system accepts an inbound email and runs it through three agents, each with one job:

  • Company Research takes the sender’s domain and builds a profile of the company from public sources: DNS records and the home page.
  • Intent Analysis reads the message and classifies it: what the sender wants, how urgent it is, what is still missing.
  • Lead Qualification takes both results and produces a score from 0 to 100, a priority, one next step and a short reply draft in the sender’s language.

The first two do not depend on each other and run in parallel. The third waits for both. If one of the first two fails, the others still finish and the lead is qualified from what exists.

The results go to a single service, the golden-record-writer, which is the only thing allowed to change the CRM tables. It checks each result against its schema, merges it into BigQuery and writes an audit row with the state before and after. A model only ever returns a proposal; ordinary code decides whether it is written.

An email in the CRM: lead score, priority and next step, the draft reply, the token and latency figures of each agent, and the change log of the Golden Record

The CRM is a read-only web app over the Golden Record. The inbox lists emails with intent, priority, score and next step. An email page shows the text, each agent’s conclusion, the draft reply, the model, token count and latency of every agent run, and the audit entries with the fields that changed. Contacts and companies are separate records: a second email from the same sender attaches to the existing contact instead of creating a new client.

The Pipeline

Flowchart of how an inbound email is processed GOOGLE CLOUD · europe-north1 Dashed frames: our code on Cloud Run Everything else: managed by Google CLOUD RUN · ingest CLOUD RUN · worker CLOUD RUN · lead-qualification CLOUD RUN · golden-record-writer Inbound email received Resend receives the email and sends a webhook to ingest ingest: is the signature valid? no 401 · rejected request dropped yes ingest: is email_id already in Firestore? yes 200 · ignored duplicate event no Save email_id, publish event ingest → Firestore, Pub/Sub Pub/Sub delivers the event managed · at-least-once worker fetches the email Resend API → raw email in BigQuery worker: processed OK? no Retry with backoff after max attempts: → dead-letter topic redeliver yes worker publishes email-stored Pub/Sub → Eventarc → Workflows Company Research Cloud Run · Gemini DNS · website Intent Analysis Cloud Run · Gemini type · need · urgency partial result if one fails Lead Qualification Cloud Run · Gemini score · priority · draft reply Spam? yes No draft reply low priority no golden-record-writer: proposal valid? no Rejected nothing is written yes writer: is the sender a known contact? yes Attach to existing contact and company no Create contact and company new Golden Record entities MERGE into BigQuery Golden Record + audit row CRM shows it Cloud Run web app · inbox, clients Resend Resend owns the inbound address. It accepts the mail, stores it and notifies us with a webhook. The full message is fetched later by id through the Resend API. Ingest · Cloud Run service ingest is our own code, packaged as a container and deployed to Cloud Run: Google runs it, scales it with traffic and stops it when idle. It is public, because Resend must reach it; the signature is its protection. The signing secret lives in Secret Manager. Resend signs every webhook (svix-id, svix-timestamp, svix- signature) over the raw body. A wrong signature means the request did not come from Resend, so ingest rejects it with 401 before doing anything else. Duplicate check Resend can deliver an event more than once. ingest creates a Firestore document keyed by email_id; creation fails atomically if it already exists, so a repeat is answered with 200 and dropped. Firestore is a serverless document database (JSON-like documents, no SQL). BigQuery cannot do this: it does not enforce unique keys. Queue · Hand-over ingest (pronounced in-jest) is the front door: it accepts, checks and hands over, with no analysis. It marks the email as received, publishes the event to Pub/Sub, then marks it as published. If publishing fails it answers 503 and Resend retries; the retry finds the pending record and publishes again, so no event is lost and none is sent twice. Pub/Sub A message queue between the services. A push subscription calls the worker over HTTPS with a Google-signed token, and only that identity may invoke the private worker. Delivery is at-least- once, so a message can arrive twice and every consumer has to tolerate that. Worker · Cloud Run service (private) A separate Cloud Run service, triggered by Pub/Sub. It reads the full message (html, text, headers, SPF/DKIM/DMARC results) from the Resend API and stores the raw email in the BigQuery table crm.raw_emails with a MERGE keyed by email_id, so a redelivered message cannot create a second row. The Resend API key lives in Secret Manager. Failures If the worker fails, Pub/Sub redelivers with backoff. Retries back off from 10 to 600 seconds. After 5 attempts the message goes to a dead- letter topic with its own pull subscription, so it is kept for inspection and nothing is lost silently. Orchestration · Cloud Workflows The worker publishes email-stored. Eventarc starts one execution per event. Delivery is at-least-once, so every step must be idempotent. Agents Company Research looks at the sender domain (DNS, website; no company is invented for a public mailbox) and Intent Analysis reads the email. Each agent is its own Cloud Run service with its own service account and minimal permissions. It calls Gemini on Vertex AI with a JSON schema, treats the email as data and drops quotes not found in the email. Lead Qualification Combines both results and proposes a score, priority, next step and a draft reply. It only proposes: the model never sends or writes anything itself. Spam Spam gets a low priority and no draft reply, so the system never produces a response to junk. Golden Record · Validation The model output is only a proposal. golden-record-writer, a Cloud Run service and the only one allowed to write, validates it in plain code (schema and rules) before anything is stored. Invalid proposals are rejected and nothing is written. Entities A contact is the sender address and a company is the sender domain. Public mailboxes like Gmail get no company. A repeat email updates the same records, never a duplicate. Golden Record A MERGE upserts; BigQuery does not enforce unique keys, so the writer does. One transaction writes the interaction, recomputes counts from all of a contact's emails and adds audit rows with before and after. CRM · Cloud Run service This web app is a Cloud Run service too. It reads the Golden Record from BigQuery and shows the inbox, clients and the analysis of each email, with the source of every conclusion.

Every box on the Google Cloud side is its own Cloud Run service with its own service account. The private ones accept calls only from the identity that is meant to call them.

1. Ingest accepts the message

The mail provider stores the message and calls a webhook with the email ID and metadata, not the body. Ingest is the only endpoint open to the internet, so its protection is the signature: it verifies it over the raw body before doing anything else. It then creates a Firestore document keyed by the email ID. The creation fails atomically if the document exists, so a repeated delivery is answered with 200 and dropped. BigQuery cannot do this, because it does not enforce unique keys.

Ingest marks the email as received, publishes the ID to Pub/Sub and marks it as published. If publishing fails, it answers 503 and the provider retries; the retry finds the pending record and publishes again. No event is lost and none is sent twice.

2. Pub/Sub and the worker store the email

A push subscription delivers each message to the worker with a Google-signed identity token. Failed deliveries are retried with backoff from 10 to 600 seconds, and after 5 attempts the message moves to a dead-letter topic, so nothing is dropped silently.

The worker fetches the full message from the provider API and writes it to BigQuery with a MERGE that inserts only if the email ID is new. It then publishes an email-stored event. Any failure returns HTTP 500, so Pub/Sub redelivers, and every step is safe to repeat.

3. Eventarc and Cloud Workflows run the agents

Eventarc starts one Cloud Workflows execution per email-stored event. The worker knows nothing about the orchestration, so more subscribers can be added without touching it.

The workflow runs Company Research and Intent Analysis in parallel, then Lead Qualification with their results, then the writer. Transient failures (429, 502, 503, 504, timeouts) are retried up to three times with exponential backoff. If an agent still fails, its result is marked as failed and the others continue. Each run is visible step by step in the Workflows console.

The Agents

All three share one wrapper around Gemini 2.5 Flash on Vertex AI. It forces structured output against a Pydantic schema, applies a timeout, and logs model name, prompt tokens, output tokens and latency for every call. The CRM shows those figures for each email, so cost and speed are visible per agent.

Company Research

Takes the sender’s domain. Public mailbox providers (Gmail, Outlook and similar) get no company at all: the agent does not guess a company from a signature it cannot verify. For a business domain it looks up MX, SPF and DMARC records, fetches the home page and asks Gemini for a profile (name, what the company does, industry, size hint, confidence, supporting quotes) based only on that page text. If the site cannot be read, the result is ‘unknown’ and no profile is made up.

The domain is controlled by the sender, which makes the page fetch an SSRF risk. The fetch connects only to public IP addresses, checks every redirect hop, pins the connection to the address it checked, accepts only http(s) on default ports and limits time and response size. A crafted domain cannot reach internal services or the cloud metadata server.

Intent Analysis

Reads the email from BigQuery and returns a verdict that must match a fixed schema: intent (demo request, pricing inquiry, partnership, support request, recruiting, vendor solicitation, spam, other), urgency, summary, requested action, language, confidence and supporting quotes. Quotes that do not appear verbatim in the email are dropped, so the evidence shown in the CRM is always real text from the message.

Lead Qualification

Called by the workflow after both agents finish, with the email ID and their results. A failed or missing result is left out and the model is told to lean on the email alone. Gemini returns a score, a priority, a rationale, one next step (book a demo, answer a question, route to support, route to recruiting, no action) and a reply draft in the sender’s language.

Rules in code have the last word. Spam is forced to a low score with no reply, ‘no action’ never carries a draft, and a low-confidence lead cannot be high priority. The service has no tool for sending mail, so the draft stays a draft.

The Golden Record

The writer is the only service with write access to the CRM tables. A malformed result is rejected and nothing is written. Otherwise one BigQuery transaction upserts the email into interactions, rebuilds the sender’s contacts row and the domain’s companies row from all their interactions, and appends rows to audit_log with the state before and after.

A contact is the sender address, a company is the sender domain, and public mailbox providers get no company. Counts and dates are recomputed from the interactions, not incremented, so a repeated run cannot inflate them. A company profile is replaced only by one with equal or higher confidence, and an agent that failed in a later run never erases what an earlier run stored. Audit rows are written only when something actually changed.

Every row gets a UUIDv7. It starts with a timestamp, so keys sort by creation time and keep inserts and clustered scans local. CRM URLs use these identifiers, so addresses and domains never appear in links.

Design Guarantees

ConcernHow it is handled
Duplicate deliveryFirestore claim in ingest, insert-if-new MERGE in the worker, keyed upserts in the writer: every queue may deliver twice and the result is the same
Failures are visibleAutomatic retries, a dead-letter topic for exhausted messages, structured JSON logs in Cloud Logging with the email ID on each line
AlertsCloud Monitoring emails on a dead-letter message, a failed workflow execution, a service ERROR or a 5xx; a monthly budget alert watches the cost
Scheduled recoveryA nightly workflow finds emails from the last seven days where an agent failed and publishes them again; the idempotent writer fills in only what is missing
Least privilegeEach service has its own service account. Agents can read the email table but cannot write it; only the writer can change the CRM tables
SecretsProvider keys and web app credentials live in Secret Manager and are injected at runtime. Nothing secret is in the repository or the image
Untrusted inputEmail text, website text and earlier agent results go to the model as data inside delimiters, with an instruction not to follow them, and the answer shape is fixed
Cost and latencyEvery model call logs model, prompt tokens, output tokens and latency, and the CRM shows them per email

Evaluation

The agents are checked against 24 labelled test emails: corporate requests, pricing questions, partnership offers, support requests, spam and messages in several languages. Each case has an expected intent and next step, and some carry a cap on priority, because the expensive mistakes are not symmetric. Spam that gets a high priority costs more than a missed nuance between two kinds of sales message.

The harness calls the live model, so a change to a prompt or a model version shows up as a number instead of an impression.

MetricResult
Test emails24
Intent correct23 of 24 (95.8%)
Next step correct20 of 24 (83.3%)
Priority cap respected24 of 24
Latency per email, median6.0 s (two model calls)
Latency per email, maximum11.8 s
Tokens across all cases19,346 in, 7,197 out

The misses are instructive. Two pricing questions got ‘book a demo’ instead of ‘answer a question’, a partnership and a general message were routed to support, and one email that tries to order the model to rate it as a high-priority demo request was called spam instead of vendor solicitation. The injection failed where it matters: the lead stayed at low priority with no action. The other misses are judgement calls between two reasonable next steps, which is why a person reads the result before anything goes out.

Stack

Python 3.13, FastAPI, Jinja2 and HTMX for the web app, Pydantic for every schema. Google Cloud: Cloud Run, Pub/Sub, Eventarc, Cloud Workflows, Cloud Scheduler, BigQuery, Firestore, Secret Manager, Cloud Logging and Monitoring, and Vertex AI with Gemini 2.5 Flash. Inbound mail arrives through Resend. The project is built with uv, ruff and pytest; 73 tests cover the services, the agents and the CRM pages.

This is a personal project, built end to end: the delivery guarantees, the trust boundary around the model and an evaluation loop that shows when a prompt change made things worse.

Iurii RoguliaAvailable

Need something similar?

I build custom solutions – from APIs to full products. Let’s talk about your project.

View all projects

Related projects

kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
September 28, 2026

Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.

Stack

Next.jsReactHonoTypeScriptNode.jsTailwind CSSTurborepoDocker

Databases

PostgreSQLRedis
pikkuna.fi – AI-Powered Localization Pipeline
pikkuna.fi – AI-Powered Localization Pipeline
July 30, 2026
pikkuna.fi – AI-Powered Localization Pipeline

A production localization pipeline: OpenAI translates, another vendor’s model verifies, a separate AI agent proofreads, pytest guards structure.

Stack

Python

Related posts

Zod at the Boundary: Validating Webhooks and Form Submissions at Runtime
October 16, 2026· 9 min
Zod at the Boundary: Validating Webhooks and Form Submissions at Runtime

Why TypeScript types disappear at runtime, and how to use Zod to validate webhook payloads and form submissions at the boundary.

Stack

TypeScript
Domain Expiry and SSL Certificate Monitoring You Can Self-Host
October 14, 2026· 7 min
Domain Expiry and SSL Certificate Monitoring You Can Self-Host

Why registrar and CA renewal emails leave gaps, what a self-hosted domain and SSL expiry check needs to do, and when it beats paid monitoring SaaS.

Stack

Python