EMS Consulting
A recruitment consultancy's site, CRM and inbox on Postgres
EMS places engineers and site teams with construction and infrastructure companies. I rebuilt their public site as a lead engine, then built the back office they run on: candidate intake, employer requirements, a CRM with its own email inbox, invoicing and a recruiter workspace.

// The Problem
The consultancy needed its website to bring in candidates and employers, and a back office to track them. Enquiry email went to a personal inbox, with no shared record of who had been contacted.
The constraint for the email system: it had to cost nothing to run.
// How It's Built
Cloudflare Email Routing
Inbound for info@, free, messages up to 25 MiB
Email Worker
Parses the message and uploads attachments straight to storage
Signed webhook
HMAC, constant-time check, dedupe on Message-ID
One Postgres function
Dedupe, threading, insert and unread count in one transaction
CRM inbox
A view with a LATERAL join returns 100 rows, not 2,943
// Decisions & Trade-offs
- 01
Free inbound, paid-for nothing
Inbound runs on Cloudflare Email Routing and outbound on Resend's free tier. Cloudflare's own sending needs a paid plan, so it isn't used.
- 02
Attachments skip the webhook
The serverless request body tops out near 4.5 MB while Cloudflare accepts 25 MiB messages, and the large messages (CVs) are exactly the ones worth keeping. The Worker writes attachments to storage directly.
- 03
Contacts keyed on the header From
Many providers send from a unique bounce address per message, which was creating a new contact for every email. Matching on the header From fixed it, since SPF and DKIM have already been checked by then.
- 04
Workspace routing by hostname, outside middleware
The recruiter workspace lives on its own subdomain through a host-matched rewrite, so public pages never pay for an auth round trip. It has its own login because session cookies are host-only.
// Problems Worth Telling
- 01
Zero CVs collected
After launch the client reported that CVs weren't arriving. The form allowed 5 MB but Server Actions default to a 1 MB body, so every real CV was rejected before my handler ran: the database held zero submissions. Raised the limit and confirmed with real uploads the same day.
- 02
Concurrent deliveries corrupted threads
Reproduced with 12 simultaneous deliveries: 4 threads instead of 1 and an unread count of 2 instead of 12. A read-then-update lost increments and thread creation raced. Ingest is now one Postgres function with an advisory lock per sender: 1 thread, 12 messages, unread 12.
- 03
Opening an unread thread crashed the page
Marking a thread read called revalidatePath during render, which Next 16 throws on. It went unnoticed because the page sits behind a login. Read-marking now runs after the response.
// Quality
- Performance measured before and after, never estimated: the list, the ingest path and the thread view each have their numbers in the commit that changed them.
- Row-level security with public reads only for open jobs and active services; every lead table is server-only.
// Where It Stands
- Live at emsconsulting.in. The consultancy runs candidate intake, its inbox, invoicing and submission packs on it.
// Stack
Building something similar?
Let's talk