Skip to main content

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 User row.
info

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

ChannelHow it arrivesDraft shape
Inbound emailA CV emailed to cv+{slug}@inbound.aiqlick.com is parsedInboundEmail with emailType = "CV", processingStatus = "CV_CREATED", extractedTalentId null until promotion
Bulk ZIP importEach CV in an uploaded ZIP is extractedSynthetic InboundEmail(CV_CREATED) via save_intake_draft(), imapFolder = "INTAKE"
AI agentThe agent's extract_cv tool parses a CV documentSynthetic InboundEmail(CV_CREATED) via save_intake_draft()
Multi-CV emails split into one draft per CV

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:

ActionWhat it does
Preview extracted CVOpens the DraftCvPreviewModal showing extracted skills, experience, and projects
Open emailNavigates to /company/inbound-emails/{id} to see the raw email
DeclineRemoves the draft from the queue — no talent is created
Review & completeOpens 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
Completion is judged against the extracted CV, not the User row

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).

Auto-refresh

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.com hosts and the aiqlick-uploads bucket 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 d query parameter
Per-attachment "View Talent" button rolling out

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 typeMutationEffect
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's InboundEmail container 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 promote
  • firstName, lastName, email, title (required) — the candidate's identity fields
  • sendConnectionInvitation (optional, default true) — 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 === true and the flag is true. Previously a request was always sent; now the default sendConnectionInvitation: true only 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:

LabelValuesMeaning
Recruiter StatusDraft / Completed / DeclinedWhere the recruiter's review stands
CV lifecycle StageCV Extracted / Created / Ignored / ParsingWhere 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

ActionWho Can Perform
View the Drafts inboxAny authenticated company member
Preview an extracted CVAny authenticated company member with access to the draft
Decline a draftCompany 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.