Skip to content

← Side Quests

Verric

Verric is a self-hosted email verification product: real-time checks on a single address, and bulk CSV cleaning for a whole list. It exists because mailbox validity is frequently not observable. A resolver times out, a provider accepts every recipient it is offered, outbound port 25 is blocked — and most tools resolve that ambiguity by picking a side. Verric keeps it, and every result carries the evidence behind it, including the case where the honest answer is that nobody can tell.

I designed and built all of it as an independent product. The code is private, and I'm happy to walk through it live on a call.

At a glance

  • Role: Independent builder across product, backend, worker, data model, dashboard, and deployment.
  • Stage: Working self-hosted product with 204 passing backend tests; external adoption and live verification accuracy are not yet measured.
  • Core constraint: Preserve DNS and SMTP uncertainty while processing large lists on a small VPS.
  • Strongest evidence: Streaming ingestion, PostgreSQL-backed range jobs, separate API and worker processes, provider-aware decisions, and network safeguards.

Context

Verification is sold as a binary answer, and the two ways of being wrong cost different things. A false invalid quietly discards a real contact. A false valid sends mail to a dead mailbox, and enough of those damage the sender reputation the entire list depends on.

The evidence available to make that call is uneven:

  • SMTP recipient probing is the only direct signal about a mailbox, and it is not always available. Port 25 is blocked on many networks, and providers refuse or throttle probes for reasons of their own.
  • Several large consumer providers, the Yahoo and AOL family among them, accept every recipient offered. The response says nothing about whether the mailbox exists.
  • Infrastructure failure looks like a negative result. A resolver timeout and a domain that genuinely publishes no mail exchanger arrive at the same place in the code unless something deliberately keeps them apart.
  • Bulk work is expensive in precisely the resources a small deployment is short of. Probes hold sockets for seconds at a time, a CSV can carry hundreds of thousands of rows, and all of it runs on one VPS that is also serving the API.

So the design problem was never the check itself. It was reporting what the system actually learned, across a set of failures that are indistinguishable from the outside, at list scale, on one machine.

My role

I own the problem definition, the result contract, the backend architecture, the verification engine, the queue and worker design, the database schema, the dashboard, and the deployment shape.

Decisions that would usually be argued out across teams — how much uncertainty an API should expose, whether throughput or sender reputation wins, where a long-running job is allowed to execute, what a cache is permitted to remember — were mine to make, and they had to hold together as one system.

Architecture

Two processes and one database. The API validates input, owns the job lifecycle, and serves results; it never performs verification itself. The worker consumes queues and does the slow work: parsing uploaded files, resolving DNS, and probing SMTP under rate control. PostgreSQL is the record of truth for jobs, job rows, verification results, and cached domain intelligence. Redis carries references to work rather than the work itself. The dashboard is a Next.js client that proxies the API same-origin, polls job progress, and sits behind a signed HTTP-only session cookie.

Public-safe Verric bulk pipeline showing streaming upload, PostgreSQL rows, lightweight range jobs in Redis, a separate worker, and evidence-aware results.

Long-running work never executes inside a request handler. An SMTP probe can hold a socket for seconds and a CSV job can run for a long time, so putting either in the HTTP path means bulk execution and request latency compete for the same process. Separating the API from a queue worker costs deployment complexity and requires the worker to stay healthy, and it buys the ability to reason about the two workloads independently.

The result contract is the product. A bare status would erase the exact thing Verric exists to preserve, so every verification returns a status alongside a score, a reason, the individual checks that ran, a recommendation, a timestamp, and an explicit SMTP-confirmed flag. The engine orders cheap evidence before expensive evidence — syntax, freemail and role-account and typo metadata, domain age and blocklist observations, cached domain intelligence, then MX resolution, disposable-domain checks, and finally rate-limited SMTP probing — and the result keeps those layers distinguishable rather than collapsing them into one verdict.

Two boundaries let user input reach something dangerous, and both are checked before it gets there. An email address determines which host the worker opens a socket to, so MX targets are resolved and rejected when they point into private, loopback, or link-local space, which stops address input from being turned into an SSRF probe of the host network. An uploaded file determines what the parser reads, so extension and file signature are both validated, including rejecting spreadsheets disguised as CSVs.

Stack

Backend — Bun · Elysia · TypeScript · PostgreSQL · Zod

Queueing — BullMQ · Redis

Frontend — Next.js 16 · React 19 · TypeScript · Tailwind CSS · shadcn/ui · TanStack Query · Zustand

Verification signals — DNS MX · SMTP recipient probing · disposable-domain data · RDAP domain age · DNSBL lookups

Delivery — Docker · VPS · OpenAPI and Scalar

Engineering challenges

A DNS failure that became a permanent fact

The problem. Domain intelligence is cached, because resolving MX records again for every row of a large list would be both slow and rude. An earlier version of that path treated a failed lookup as a domain with no mail exchanger and cached it. From that point on, every address at the domain came back invalid, long after the resolver had recovered.

Why it was hard. The failure was silent and self-confirming. A cache hit looks like a fast, correct answer, and nothing in the stored observation distinguished asking and learning the domain has no mail exchanger from asking and getting nothing back. Caching also made the corruption spread: one bad moment during one job affected every job afterwards. And the wrong rows were already written, so fixing the logic alone would have left the false invalids in place.

The decision. Treat infrastructure failure as its own outcome instead of a value of the answer. A resolver error is not evidence about a domain, and nothing that cannot be trusted is allowed to be remembered.

Implementation. DNS errors are modeled separately from a confirmed absence of MX records. Infrastructure failure returns unknown rather than invalid, and never writes a no-MX observation to the domain cache, so a bad lookup cannot become a stored fact. Regression tests hold the distinction in place, and a cleanup utility clears the domain-intelligence rows written under the old behavior.

Outcome. The bug class is closed where it was created rather than where it surfaced: the cache can no longer learn something untrue from a failure. The data already poisoned was repaired instead of left to keep producing wrong answers.

Providers that answer every question with yes

The problem. SMTP recipient probing is the direct signal, and for some large consumer providers it carries no information. The Yahoo and AOL family accept every recipient offered whether or not the mailbox exists. Other providers refuse for policy or reputation reasons that say nothing about the address, and transport itself can simply fail. Three different meanings, one protocol, and little in the response to separate them.

Why it was hard. Every clean resolution is a lie in one direction. Reading accept-all as valid manufactures addresses that do not exist. Reading it as invalid destroys real contacts at the most common consumer domains in any list. Heuristics — local-part shape, gibberish and typo patterns, provider-specific behavior — carry genuine information but not proof, and folding them into a binary status makes an educated guess indistinguishable from mailbox evidence. Establishing how accurate those heuristics actually are is a calibration problem that needs a labeled benchmark, not a better guess.

The decision. Preserve the ambiguity in the result instead of resolving it inside the engine, and keep heuristics visibly labeled as heuristics in the payload.

Implementation. Verification returns one of four outcomes: valid, invalid, risky, or unknown. The SMTP-confirmed flag and decision-source fields let a consumer see whether a verdict rests on direct mailbox evidence, on infrastructure signals, or on constrained heuristic scoring. Behind that contract sit provider classification, transport and provider health tracking, retry and zero-retry controls, local-part ranking, and feature flags, with regression fixtures covering known provider failure modes.

Outcome. The decision about what to do with an ambiguous address moves to the consumer, who has the context the engine does not — enough to treat a cold outbound list differently from a password reset. Risky and unknown are answers the system returns, not failures to produce one.

A list too large to hold in memory

The problem. Bulk cleaning means accepting a file that may carry hundreds of thousands of rows, then verifying every one, on a single VPS that also has an API to serve.

Why it was hard. The obvious pipeline holds the whole list two or three times over: buffered upload in the API process, parsed array, then the addresses again as a queue payload in Redis. Every copy is harmless at ten thousand rows and fatal at half a million. Verification is also slow on purpose, since probes are rate limited per domain, so a job stays alive for a long time and holds whatever it is holding for the duration.

The decision. Nothing that grows with list size gets held in a process. The database is the list, and everything else refers to it.

Implementation. Uploads stream to disk instead of being buffered in memory. A queued ingest job parses rows and inserts them into PostgreSQL in batches. Verification is then scheduled as numeric database ID ranges, so Redis carries a pointer to a slice of the list rather than the addresses themselves, and the worker reads each slice when it reaches it. Guards bound upload size, pending disk usage, pending email count, and concurrent uploads, and the ingest path is configured to accept lists up to 500,000 rows. Results persist as they are produced, and can be reviewed in the dashboard or exported by category.

Outcome. Peak memory is set by batch and range size rather than by the file the user happened to pick, which is what makes bulk verification viable on the same small machine that serves requests.

Treating SMTP capacity as a reputation budget

The problem. The real cost of verification is not CPU. Probing mailboxes from one source IP spends the reputation of that IP, and a blocked IP takes the product down with it. Uploading a file and verifying it started out as a single action, so a mistaken or misunderstood upload immediately began spending that budget.

Why it was hard. Throughput and safety pull against each other and no single setting satisfies both. Speed is the point of a bulk tool, and speed means many simultaneous connections to the same provider, which is exactly what gets a source IP throttled. The provider families that need the most care are also the ones most heavily represented in a real list. Separating upload from verification meant changing the job lifecycle the client was already built against.

The decision. Make starting verification an explicit act, and limit per domain rather than globally.

Implementation. Upload and verification are separate operations. After ingest finishes, a job stops at ready and waits to be started, so preparing a file has a clear boundary and nothing consumes SMTP capacity by accident. The worker enforces per-domain concurrency limits and a minimum delay between probes, with stricter behavior for provider families known to be sensitive, and running jobs can be stopped, resumed, or deleted. Splitting the lifecycle was a breaking API change, so the dashboard's upload flow was reworked to the prepare-then-start shape alongside it.

Outcome. Throughput is deliberately held below what the machine could manage, in exchange for not gambling the source IP that everything else depends on. The limiter lives in worker memory, which is the right shape for the single-worker deployment Verric runs as and an explicit boundary on it: a horizontally scaled worker pool would need distributed coordination to hold the same guarantee.

Outcome

Verric works as a self-hosted product. Real-time single-address verification returns the full explainable result contract, and the bulk path runs the complete lifecycle: upload, ingest, an explicit start, progress polling, stop and resume and delete, in-browser review of results, and categorized export. The API is documented through OpenAPI, and the system is packaged for Docker and VPS deployment with the API and worker operated as separate processes.

The backend carries 204 tests across 30 files, concentrated where a mistake turns into a confidently wrong answer about someone's address: verification and scoring, provider-specific behavior, DNS and MX semantics, private-IP screening, CSV validation and streaming, and ID-range handling.

What I would keep from this project is the discipline about evidence. Most of the hard decisions came down to refusing to let one kind of knowledge impersonate another — a resolver failure is not a fact about a domain, an accept-all response is not a mailbox, a heuristic score is not confirmation. Unknown is a result the system is allowed to return, and building it that way is what makes the other three worth trusting.