Internal CRM for an ops team: one screen instead of five tabs
Shared inbox, live Shopify data, shipments, calls, survey answers and returns for a customer — all in one window. Built on the Python standard library, running on a 961 MB droplet.
Answering "where is my order" meant assembling data from five systems
A US outdoor-gear brand running two Shopify stores. Two ops people handled customers by switching between two Shopify admin panels, an email client, a cloud phone system and warehouse spreadsheets.
The most common customer question is "where is my order". Answering it meant: find the email, pull the order number out of it, remember which of the two stores that order belongs to, open the right admin, find the shipment, go back to the inbox and write the reply. Five context switches for one line of text.
Chargebacks for one of the stores were invisible entirely: they landed in an interface the ops team simply never opened. They found out after the fact.
The first version of the customer card took 10 seconds to load — and the ops person said so, bluntly. That became the main technical requirement: a tool that opens slower than manually switching tabs will not get used.
A custom CRM on the standard library
An HTTP server on
http.server
from the standard library, with no web framework. Not out of aesthetics: this way it
installs on a server that has no pip and no permission to get one. Zero external
dependencies means nothing to break on an upgrade and nothing to repair six months later.
IMAP every 15 minutes: two corporate mailboxes, over 100k messages
Threading, classification, streamed straight to disk
Address → line offsets; a separate index of order numbers found in message bodies
Six sources in parallel: orders from both stores, shipments, calls, survey, returns
Templates, signatures, AI draft, send and forward with attachments via a queue
Six sources in parallel — and honest degradation
The customer card fans out to six independent sources at once: orders from the first store, orders from the second, shipments, call history from the cloud phone system, post-purchase survey answers, and returns with chargebacks.
The key detail is a thread-local failure counter that gets carried back into the main thread. Without it, a source that fell over would simply not render, and a partial card would silently pass for a complete one. The ops person would read "no orders" where the truth is "the store API did not answer just now". Those are different facts, and you tell the customer different things.
Indexes instead of streaming the whole corpus
The two mailboxes hold over 100k messages — a corpus in the hundreds of megabytes. The first version read it end to end on every card view: hence the 5.8 seconds to pull a customer's email history.
I built two indexes:
- Address → line offsets — open the corpus, seek to the offset, read only the messages that matter
- Order number → customer — an index of order numbers extracted from message bodies: the buyer writes from a personal address while the order sits under a work one, and the card is still found
Streaming parser: otherwise OOM-kill
The mail parser originally held the parsed corpus in memory — on a 961 MB droplet that ended in an OOM-kill. I rewrote it to stream: messages are written to disk one at a time and only a light header projection stays in memory. Peak memory dropped from 985 MB to 256 MB (215 MB on the server itself), with a byte-identical result: the same message and thread counts, and a matching sha256 of the contents.
Everything needed to never leave the window
- Sending and forwarding with attachments through a queue — the UI never blocks on SMTP
- Per-person templates and signatures
- AI reply draft — built from the thread context and the card data, edited before sending
- Personal accounts with device memory — no re-login every morning
- An audit log of "who / what / how it ended" for every action
- A warehouse stock block pulled from Lark — inside the card, no separate spreadsheet
- Full mobile layout — verified at 390px, replies can go out from a phone
Move to a dedicated server — in one day
The CRM initially lived on a shared box alongside the tracker — a neighbourhood where any load spike hits both. I moved it to a dedicated server behind nginx + Let’s Encrypt, with a 15-minute IMAP sync, an hourly monitor and nightly backups. The migration was accepted on a parity check: messages, threads and dashboard orders matched the old installation one for one on every counter.
Zero external dependencies — on purpose
http.server, imaplib, sqlite3, urllib — installs on a server with no pip
Live orders and shipments from both stores, no intermediate database
Custom mail parser, classifier and indexer
Reply draft from thread context — the operator edits and sends
Dedicated server, TLS via certbot, sync and monitors on timers
454 collected tests, 28 test files against 15 code modules
Measurements before and after
six sources fetched in parallel
offset index instead of reading the whole corpus
215 MB on the server itself; result byte-identical
The ops team now works in a single window: the email, orders from both stores, the shipment, calls, survey answers and returns — on one screen, with a reply already drafted. Chargebacks stopped being a blind spot — they sit in the same card as everything else about the customer.
The memory work was not verified by eyeballing it: after the parser rewrite we compared message counts, thread counts and the sha256 of the contents — all matched. This is the class of change that has no right to alter the data, and that has to be proven rather than asserted.
The move to a dedicated server took one day and was signed off on parity with the old installation. The project carries 120 commits in the CRM module and 454 collected tests (28 test files against 15 code modules): an internal tool, but not a throwaway script.
Where else the same methodology applies
This is not "a CRM for an online store". It's the standard task of "collapse N systems into one screen around one entity" — wherever a person performs a manual join between browser tabs every day:
- → Support on a shared inbox — email + system of record + delivery in one thread instead of three tabs
- → Multi-channel sales — several storefronts and marketplaces, each with its own admin, while the customer is one person
- → Field service operations — the ticket, call history, parts stock and work-order status for one client
- → Mail archives as a data source — indexing a message corpus and searching by document numbers inside the bodies
- → Replacing an off-the-shelf helpdesk — when the per-seat bill keeps growing and what you actually need is two or three specific tabs the product doesn't have
- Parallel card assembly with honest failure accounting — partial data is never passed off as complete
- Mail corpus indexer: address → offsets and document number → customer
- Streaming processing instead of "load it all into memory" — runs on the cheapest VPS tiers
- Migration verified by counter and hash parity, not by "it seems to open"
- An action audit log on the "who / what / how it ended" schema
If your team joins browser tabs by hand — that collapses into one screen
We start from the single question the team answers most often every day, and build exactly the screen that answers it. First working version in weeks, not quarters — reviewed immediately by the person who will live in it.
The 5,000 ₽ audit — with a concrete report and quote
I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).
Or just send your question — I reply within 2 hours