Most staffing firms have one mailbox doing four jobs. A hiring manager sends a req in prose. A candidate replies to a job ad with a resume attached. A client's AP clerk asks why an invoice covers 41 hours. Someone's background check provider sends a status email nobody reads until onboarding stalls. All of it lands in jobs@ or info@, and one coordinator sorts it by hand between other work.
This is usually the second or third workflow we rank in a Workflow Assessment, and it is a good first build because the failure mode is mild: a misclassified email sits in a review queue instead of going to the wrong place. Nothing is sent, nothing is rejected, nothing is deleted. Here is how to build it.
Step one: read the mailbox without breaking it
Do not point a script at a recruiter's personal mailbox. Create a shared mailbox, or use the existing one, and give your integration an application identity against it.
On Microsoft 365, that is an app registration with Mail.Read and Mail.ReadWrite application permissions, scoped with an application access policy so the app can only see the one mailbox. The message list is a normal paged call, documented at learn.microsoft.com/graph/api/user-list-messages:
curl -s "https://graph.microsoft.com/v1.0/users/jobs@example.com/mailFolders/Inbox/messages?\$top=50&\$select=id,subject,from,receivedDateTime,conversationId,hasAttachments" \
-H "Authorization: Bearer $ACCESS_TOKEN"
On Google Workspace the equivalent is a service account with domain-wide delegation and gmail.readonly plus gmail.modify. IMAP still works if that is what you have, but you lose thread ids and you will rebuild threading badly.
Two rules from experience:
- Work in threads, not messages.
conversationIdon Graph,threadIdon Gmail. A req and its three follow-ups are one unit of work. - Never let the agent delete or move mail as its only record. Mark with a category or label, and keep your own row. A triage system whose entire state lives in someone's Outlook folders is one drag-and-drop away from amnesia.
Step two: one row per thread
create table inbox_threads (
thread_id text primary key,
mailbox text not null,
first_seen_at timestamptz not null default now(),
last_message_at timestamptz not null,
sender_email text not null,
sender_domain text generated always as (split_part(sender_email, '@', 2)) stored,
subject text,
classification text, -- 'new_req' | 'candidate_application' | 'timesheet' | 'credential' | 'invoice_query' | 'vendor_noise' | 'unknown'
confidence numeric(3,2),
ats_match_id bigint, -- contact, candidate or job id, per classification
proposed_action jsonb,
state text not null default 'needs_review', -- 'needs_review' | 'actioned' | 'dismissed'
decided_by text,
decided_at timestamptz
);
Upsert on thread_id. The job can run every five minutes, crash, and rerun without producing duplicate work items.
Step three: classify cheaply before you classify expensively
A surprising share of a staffing inbox is deterministic. Run the cheap rules first and only send the remainder to a model:
- Known sender domain, known pattern. Your background check vendor, your timesheet platform and your payroll provider all send templated mail from stable addresses. Regex on sender plus subject handles them outright, at zero cost and 100% precision.
- Reply to our own outbound. If the thread started with an ATS-sent job ad, it is a candidate application. The ATS already knows.
- Attachment shape. A single PDF or DOCX from a consumer domain with no prior thread is an application until proven otherwise.
What is left is the genuinely ambiguous traffic: prose from client contacts. That is the part worth a model call, and the prompt should ask for structure, not for a decision:
{
"classification": "new_req",
"confidence": 0.82,
"extracted": {
"job_title": "Interface Analyst (Epic)",
"location": "Nashville, TN — 2 days onsite",
"duration": "6 months, possible extension",
"bill_rate_mentioned": null,
"start": "early October",
"headcount": 2
},
"quotes": [
"We need two interface analysts for about six months starting early October"
]
}
Insist on the quotes field. Every extracted value should be traceable to text the client actually wrote, and a recruiter reviewing the queue should be able to check the extraction without opening the email. Anything the model fills in without a supporting quote is a guess, and guesses about bill rate are expensive.
Send the plain-text body, truncated, with quoted reply chains stripped. Signatures, disclaimers and the last six replies are mostly tokens you pay for twice: once at the API, once in accuracy.
Step four: deduplicate against the ATS
This is the step that decides whether recruiters trust the queue. An extraction that creates a second record for a candidate already in the system makes more work than it saves.
Before proposing anything, look up:
- The sender's email against contacts and candidates. Exact match on normalised address first.
- Candidate name plus phone from the resume, if there is one, against your candidate table. Phone digits, last seven, beats name matching for recall.
- Open reqs from the same client contact in the last 30 days. A "can you also find me a second one" email is an existing req, not a new one.
Write what you found into ats_match_id and say it out loud in the queue: Matched to existing candidate #88112, last active March 2026, three prior placements. The dedupe result is often more useful than the classification.
Step five: propose, do not perform
The agent's output is a proposed action with a button next to it, not an API write. In practice four proposals cover most of the volume:
| Classification | Proposed action | Who confirms |
|---|---|---|
new_req | Draft job record with extracted fields, linked to the client contact | Recruiter owning the account |
candidate_application | Attach resume to existing candidate, or draft a new candidate record | Resourcer |
timesheet / credential | Route to the back-office exceptions list | Coordinator |
invoice_query | Forward to finance with the placement and week already looked up | Coordinator |
Low-confidence and unknown threads stay in the queue untouched, in arrival order. That is fine. The measure of this agent is not how much it automates; it is how much of the mailbox a human no longer has to read to find the two emails that matter.
The same boundary applies here as in our approval queue for agent-drafted outreach: the agent drafts, a named person confirms, and the confirmation is what hits the ATS.
Step six: log enough to argue with it
Keep, per thread: the classification, the confidence, the model and prompt version, the extracted fields, the dedupe candidates considered, the proposed action, and who accepted or dismissed it. Then you can answer the questions that actually come up in month two — which sender domains produce the most unknown, which extracted field gets corrected most often, how long threads sit before a human touches them.
One caution. If a thread contains a resume and any part of your pipeline scores or ranks that candidate, you are no longer doing inbox triage; you are running an automated employment decision tool, with the notice, record-keeping and audit obligations that follow under NYC LL144 and Illinois HB 3773. Keep triage strictly to routing and deduplication, and let the Rubric Screener side of the house carry the assessment records. Mixing the two is how a low-risk build acquires a legal surface nobody scoped for.
What it is worth
Do the arithmetic before you build. Count a week of inbox volume, split it by the categories above, and estimate minutes per thread. A firm with 400 threads a week, 60% of it deterministic vendor and application traffic, is looking at several hours a week back and, more usefully, client reqs surfacing in minutes instead of at the end of the day. A firm with 40 threads a week should not build this yet; a rule in Outlook and ten minutes each morning is cheaper than anything with a deployment.
If you want to know which bucket you are in before committing engineering time, contact us with your ATS, your mailbox volume and where the coordinator's hours currently go.