Draft CV Inbox
Every automated CV intake channel — inbound email, bulk ZIP import, and the AI agent — now produces a draft instead of dropping a talent straight into your pool. The Draft CV Inbox at /company/resume/talents/drafts is the single place where those drafts surface, so a recruiter can review the parsed CV, fill anything the parser missed, and consciously promote it into the talent pool — or decline it.
This "draft-first" model replaces the old direct path, where an emailed or bulk-imported CV immediately created a User + Talent + CV. That path had three problems it now avoids: it could overwrite an active user's own CV, it split fields across the User row and the CvProfile, and it dumped un-reviewed talents into the pool with no consent step.
Why a draft-first inbox?
A draft is a parsed CV with no identity attached yet: the CvProfile and the 11 structured CV tables plus skills are populated, but there is no User or Talent. The draft is wrapped in an InboundEmail container row (processingStatus = CV_CREATED) so all three channels share one queue and one promote/decline surface.
Keeping intake as a draft until a human acts gives three guarantees:
- Consent before pool entry — an automated channel never silently adds someone to your company pool. A recruiter reviews and promotes.
- Never overwrite an active user's CV — if an intake CV's email belongs to an already-active platform user, the platform will not attach to them or replace their CV. Instead the recruiter is routed to send a connection request (the same flow "Add Talent" uses).
- One source of truth — the extracted CV profile drives everything (prefill, the completion check, the preview), rather than a half-populated
Userrow.
Drafts created inside the app (bulk import, AI agent) are synthetic InboundEmail rows: imapFolder = "INTAKE" and imapUid = NULL. They are created by the shared helper save_intake_draft() in background-tasks/app/services/bulk_cv/talent_creator.py, which calls save_draft_results(skip_user_creation=True) and then inserts the container row.
The three intake channels
| Channel | How it arrives | Draft shape |
|---|---|---|
| Inbound email | A CV emailed to cv+{slug}@inbound.aiqlick.com is parsed | InboundEmail with emailType = "CV", processingStatus = "CV_CREATED", extractedTalentId null until promotion |
| Bulk ZIP import | Each CV in an uploaded ZIP is extracted | Synthetic InboundEmail(CV_CREATED) via save_intake_draft(), imapFolder = "INTAKE" |
| AI agent | The agent's extract_cv tool parses a CV document | Synthetic InboundEmail(CV_CREATED) via save_intake_draft() |
A forwarded email carrying several CVs creates one draft per standalone CV document. _partition_cv_documents() selects the non-inline .pdf/.docx attachments (PDFs first, files over 10 KB preferred) and excludes inline images and signatures. The first draft keeps the real IMAP uid; the extra drafts get imap_uid = NULL. Subjects are tagged (CV n of N), and non-CV attachments ride along on the first draft only. The split is best-effort — a failure to spin off an extra draft never fails the primary one.
The Drafts inbox UI
The page is titled "Drafts" with the subtitle "CVs that arrived via email, bulk upload, or the AI agent and need your review before they're ready." It is split into two groups.
Group 1 — Awaiting completion
Emailed CV drafts that are not yet talents. Each row shows the candidate name (from the extracted CV, falling back to the sender address), attachment count, and arrival date, with these actions:
| Action | What it does |
|---|---|
| Preview extracted CV | Opens the DraftCvPreviewModal showing extracted skills, experience, and projects |
| Open email | Navigates to /company/inbound-emails/{id} to see the raw email |
| Decline | Removes the draft from the queue — no talent is created |
| Review & complete | Opens the promote modal to fill required fields and add the CV to the pool |
Group 2 — Uploaded CVs in your pool
Intake-source talents that were created by an intake channel. The INTAKE_SOURCES set that drives this list is:
BULK_IMPORT, AI_BULK_UPLOAD, AI_CV_UPLOAD,
AI_AGENT_UPLOAD, AI_EMAIL_UPLOAD, CV_UPLOAD
This group has a search box, a source filter, and two tabs:
- Needs completion — talents whose extracted CV profile is still missing a required field
- Completed — talents whose required fields are all present
Bulk and AI intake writes the parsed values to the CvProfile while User.* fields can stay null, and the resume page renders the CV profile. So the "needs completion" check reads activeCV.profile.{firstName, lastName, email, title}, not the User row — it matches what is actually on the CV.
Per-talent actions are Preview extracted CV, Open talent, Decline (only shown when the talent still needs completion), and Complete / Edit (which routes to the talent edit page).
The inbox polls every 20 seconds so bulk-import and AI-agent uploads appear without a manual reload. Those talents are written directly to the database by background-tasks with no client-visible live event, so the page refetches on an interval. (A true push — background-tasks publishing to the backend PubSub fan-out that already powers inboundEmailChanged — is the eventual upgrade.)
Attachment preview
The Preview extracted CV action renders the parsed CV inside DraftCvPreviewModal. Attachments (PDFs and raster images) are streamed through a same-origin proxy, app/api/inbound-attachment/route.ts, which fetches the presigned S3 object server-side and re-streams it inline. The proxy is SSRF-guarded:
- Only
storage.aiqlick.com/storage-dev.aiqlick.comhosts and theaiqlick-uploadsbucket are allowed - 25 MB size cap
- PDFs and raster images only
- Sandboxed CSP on the rendered response
- The target URL is passed base64-encoded in the
dquery parameter
The backend findInboundEmailById resolver enriches each attachment with extractedCvId / extractedTalentId by matching CV.url to the attachment object key, which lights up a per-attachment View Talent button. On the frontend origin/main the attachment-level extractedCvId / extractedTalentId query fields are still commented out in graphql/operations/inbound-email/queries.ts, so this specific button is not yet live — the CV-level preview is. Re-add those fields once the backend enrichment is deployed to dev.
Declining a draft
| Draft type | Mutation | Effect |
|---|---|---|
| Emailed CV draft (Group 1) | declineDraftCv (DECLINE_DRAFT_CV) | Removes the draft from the completion queue — "No talent will be created." |
| Intake-source talent (Group 2) | deleteTalent (DELETE_TALENT) | Removes the talent — these are already talents, so declining deletes the row |
mutation DeclineDraftCv($input: DeclineDraftCvInput!) {
declineDraftCv(input: $input) {
inboundEmailId
processingStatus
}
}
Input fields:
inboundEmailId(required) — the draft'sInboundEmailcontainer id
Auth: JWT required; the caller must have write access to the company that owns the draft.
The promote (complete) flow
Promoting reuses the CV that was already parsed. The promoteDraftCv mutation works from the inboundEmailId and the already-stored CvProfile + structured tables — you only supply the required identity fields the parser may have missed.
mutation PromoteDraftCv($input: PromoteDraftCvInput!) {
promoteDraftCv(input: $input) {
talentId
inboundEmailId
processingStatus
}
}
Input fields:
inboundEmailId(required) — the draft to promotefirstName,lastName,email,title(required) — the candidate's identity fieldssendConnectionInvitation(optional, defaulttrue) — whether to send a connection/collaboration request
Auth: JWT required; write access to the owning company.
Prefill, preflight, and the connection-request choice
- Prefill reads the extracted CV profile, never the email sender. The person who emailed the CV is often a recruiter (or you), not the candidate — so a missing extracted field is left blank for you to fill with the candidate's real details.
- The same email preflight as "Add Talent" runs before promotion. If the email already belongs to a talent in this company, a duplicate dialog appears. If it belongs to an active platform user (who owns their own CV), an existing-user dialog appears offering a send-connection-request choice.
- A connection request is sent only when the owner
isActive === trueand the flag istrue. Previously a request was always sent; now the defaultsendConnectionInvitation: trueonly takes effect against active users. For a brand-new email or an inactive draft owner, no request is sent. - A failed talent creation now leaves a clean draft, not an error — the parsed CV stays in the queue so you can retry.
Status taxonomy
Two orthogonal labels are shown on both the list and detail views:
| Label | Values | Meaning |
|---|---|---|
| Recruiter Status | Draft / Completed / Declined | Where the recruiter's review stands |
| CV lifecycle Stage | CV Extracted / Created / Ignored / Parsing | Where the extraction pipeline stands |
The backend adds an excludeIntake filter to InboundEmailFilterInput so the incoming-emails Inbox hides the synthetic INTAKE rows while the Drafts view sees them. Once a draft's required fields are confirmed, the TalentStatus enum in updateUser auto-promotes DRAFT → READY.
Access Control
| Action | Who Can Perform |
|---|---|
| View the Drafts inbox | Any authenticated company member |
| Preview an extracted CV | Any authenticated company member with access to the draft |
| Decline a draft | Company members with write access (canWrite) |
| Promote (Review & complete) | Company members with write access (canWrite) |
Use Cases
Reviewing an emailed CV before it becomes a talent:
A candidate emails their CV to cv+acme-corp@inbound.aiqlick.com. It lands in Awaiting completion. The recruiter previews the extracted skills, fills the title the parser missed, and promotes — the CV becomes a talent in the pool.
Bulk-importing a folder of CVs safely: A sourcing specialist uploads a 200-CV ZIP. Each CV appears under Uploaded CVs in your pool within 20 seconds. Any CV whose email matched an active platform user was left as a clean draft rather than overwriting that user — the recruiter sends them a connection request instead.
Splitting a forwarded multi-CV email:
A hiring manager forwards one email with four attached resumes. The inbox shows four separate drafts tagged (CV 1 of 4) … (CV 4 of 4), each promotable independently.
Declining noise: A CV emailed to the wrong company, or a duplicate, is declined in one click — no talent is ever created.
Related Pages
- Inbound Email Processing — how emailed CVs are received, parsed, and turned into drafts
- Bulk CV Import — the ZIP import pipeline that now produces drafts
- AI Agents — the
extract_cvagent tool that produces a draft - Talent Management — what a promoted draft becomes: talent profiles, pools, CVs