Inbound Email Processing
Inbound email automates job-posting and CV ingestion: customers email a JD or a candidate's CV to a per-company address and the platform either creates the matching Job (with parsed fields) or saves the parsed CV as a draft for recruiter review. As of May 2026 the path is event-driven (SES → S3 → background-tasks); the legacy IMAP poller is off in production.
The CV path no longer creates a User + Talent + CV on arrival. It parses the CV into a draft (a CvProfile + structured tables + skills, with no User or Talent yet) and surfaces it in the Draft CV Inbox for a recruiter to review and promote. This is the shared "draft-first" intake model used by email, bulk import, and the AI agent. See Draft CV Inbox for the promote/decline flow.
Per-company addresses
Routing uses plus-addressing on a dedicated subdomain:
| Pattern | Purpose |
|---|---|
jobs+{slug}@inbound.aiqlick.com | Job description ingestion → creates a Job |
cv+{slug}@inbound.aiqlick.com | CV submission → saves a parsed CV draft for review |
{slug} matches Company.slug exactly after normalization (lowercase, runs of non-alphanumerics collapsed to -, leading/trailing - trimmed). For example, a company called Acme Corp receives at jobs+acme-corp@inbound.aiqlick.com.
The receiving subdomain is separate from the apex — inbound.aiqlick.com (prod) and inbound-dev.aiqlick.com (dev) MX records point at SES. The apex aiqlick.com MX still points at one.com, so normal mailboxes (info@aiqlick.com, support@aiqlick.com, etc.) keep working untouched.
Both prefixes are accepted by the same parser. extract_company_tag() in background-tasks/app/services/inbound_email/normalization.py matches the local-part against ("jobs+", "cv+") — there's no separate parser per route.
Architecture
Sub-5-second end-to-end latency on the dev account; prod sees similar numbers.
Processing flow
- SES receives the email (TLS optional, virus + spam scan enabled) and matches a receipt rule on the recipient base local-part (
cv@inbound.aiqlick.comorjobs@inbound.aiqlick.com— the+slugsuffix is preserved in theTo:header but doesn't affect rule matching). - SES writes the raw RFC822 to S3 under
cvs/{message-id}orjobs/{message-id}. - EventBridge fires on
ObjectCreatedand routes to the matching SQS queue (aiqlick-inbound-jobs-{env}oraiqlick-inbound-cvs-{env}). - SqsInboundEmailConsumer in background-tasks downloads the raw bytes from S3, parses MIME headers, extracts the company tag from
To:/Cc:/Delivered-To:, and persists anInboundEmailrow keyed on a syntheticimapUid = sha256(s3-key)[:8]so SQS redeliveries dedupe via the(mailboxAddress, imapFolder, imapUid)unique constraint. - Attachment storage — every MIME attachment is uploaded to the regular S3 uploads bucket and recorded as an
InboundEmailAttachmentrow; the consumer reuses the IMAP worker's_store_attachmenthelper so behaviour matches the legacy path. - Classifier + extraction — the consumer's
on_persistedhook fires the parse:- Jobs path — runs
JobParsingServiceagainst the body text or the first attachment. The classifier rejects non-JD content withprocessingErrorCode = DOCUMENT_TYPE_MISMATCH(e.g., a CV mistakenly emailed tojobs+slug@). - CV path — runs
CVExtractionService(OCR → LLM extraction). Same classifier rejects non-CV content. On success it saves a draft: aCVrow withuserId/talentIdnull, plus the 11 structured CV tables (CvProfile,CvWorkExperience, …) viasave_parsed_schemaand the extracted skills. NoUserorTalentis created — that happens only when a recruiter promotes the draft from the Draft CV Inbox. A single email carrying several CVs is split into one draft per standalone CV document (see Multi-CV emails).
- Jobs path — runs
- Status update — the
InboundEmail.processingStatuswalks throughRECEIVED→ROUTED→PARSING→JOB_CREATED/CV_CREATED, or terminates inFAILED/IGNOREDwith aprocessingErrorCode. For CV drafts created inside the app (bulk import, AI agent), the container row is synthetic:imapFolder = "INTAKE"andimapUid = NULL.
Processing statuses
| Status | Meaning |
|---|---|
RECEIVED | Persisted from S3, no company tag yet |
ROUTED | Company resolved successfully via slug |
IGNORED | Recipient had no +tag (missing_company_tag) or attachment-required path got none (no_cv_attachment) |
PARSING | Classifier + LLM extraction in flight |
JOB_CREATED | Jobs path completed; Job row linked via jobId |
CV_CREATED | CV path completed as a draft; the parsed CV is linked via extractedCvId, but extractedUserId / extractedTalentId stay null until a recruiter promotes the draft. Despite the name, no talent exists yet. |
FAILED | companyNotFound, DOCUMENT_TYPE_MISMATCH, CV_EXTRACTION_FAILED, or generic exception. See processingErrorCode + processingErrorMessage. |
Attachment handling
The MIME parser walks all parts. An attachment is anything with a filename parameter or a Content-Disposition: attachment header — fall-through behaviour collects unknown content-types as attachments too, so a forwarded .docx resume without an explicit disposition still lands. Inline images get tagged is_inline=true and stored but excluded from CV/JD parsing.
For the jobs path, the first PDF or DOCX is the parse input and the rest are stored but ignored — the pattern is "one JD per email".
Multi-CV emails
The CV path no longer ignores the extra attachments. A forwarded email carrying several CVs creates one draft per standalone CV document:
_partition_cv_documents()selects the non-inline.pdf/.docxattachments (PDFs first, files over 10 KB preferred) and excludes inline images and signatures.- The first draft keeps the real IMAP
uid; each extra draft is a synthetic row withimap_uid = NULL. - Subjects are tagged
(CV n of N)so the drafts are distinguishable in the Draft CV Inbox. - 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 draft.
Classifier rejections
The classifier (Bedrock Haiku 4.5 OCR + classification model) runs before LLM extraction, so we don't burn tokens on non-CV/non-JD content. Rejection examples:
| Case | Status | Notes |
|---|---|---|
| Plain text JD body, no attachment | JOB_CREATED | Body text is enough if it's clearly JD-shaped (title, requirements, location). |
| HTML-only body | JOB_CREATED | Body text is extracted from HTML before classification. |
| Attachment is a release-notes doc / generic PDF | FAILED + DOCUMENT_TYPE_MISMATCH | Classifier returned OTHER or BUG_REPORT etc. |
| Attachment is a near-empty / corrupt PDF | FAILED + CV_EXTRACTION_FAILED (CV path) | OCR returned only a few characters. |
Plus-tag refers to a slug that doesn't exist in Company | FAILED + company_not_found | Common for typos; verify with inboundEmails(filter: { processingErrorCode: \"company_not_found\" }). |
Mailbox configuration
The InboundMailbox table still exists but its semantics changed. Under IMAP it stored connection details for imap.one.com; under SES it's a synthetic row whose only purpose is to satisfy the InboundEmail.mailboxId foreign key. The SQS consumer creates one synthetic row per env on first start (address: jobs@inbound.aiqlick.com, imapHost: ses.amazonaws.com, imapFolder: SES).
The legacy IMAP CRUD mutations (createInboundMailbox, updateInboundMailbox, …) still work but are unused — the IMAP worker is disabled on dev and prod (INBOUND_EMAIL_ENABLED=false).
query { inboundMailboxes(enabled: true) { id address imapHost imapFolder } }
Email queries
query {
inboundEmails(filter: {
companySlug: "acme-corp"
processingStatus: "JOB_CREATED"
createdAfter: "2026-05-01T00:00:00Z"
limit: 20
}) {
items {
id
subject
fromAddress
processingStatus
processingErrorCode
jobId
company { id companyName }
job { id title }
attachments { id filename contentType sizeBytes }
attempts { id status attemptNumber error }
}
total
hasMore
}
}
Filter options: mailboxId, companySlug, resolvedCompanyId, processingStatus, processingStatusIn, jobId, fromAddress (partial match), subject (partial match), createdAfter, createdBefore, excludeIntake.
excludeIntake (boolean) hides the synthetic INTAKE rows created by bulk import and the AI agent, so the incoming-emails Inbox shows only real inbound mail. The Draft CV Inbox leaves it off so it sees those intake drafts.
Data model
| Model | Purpose |
|---|---|
InboundMailbox | Synthetic row per env satisfying the InboundEmail FK |
InboundEmail | Email content, routing metadata, processing state. Also stores rawMimeBucket + rawMimeKey pointing at the original S3 object for re-processing. |
InboundEmailAttachment | File metadata with S3 keys and SHA-256 hashes |
InboundEmailParseAttempt | Per-attachment parse tracking — extraction status, attempt number, error message, output JSON |
mutation {
createInboundEmailParseAttempt(input: {
inboundEmailId: "email-uuid"
attachmentId: "attachment-uuid"
inputSource: "EMAIL_ATTACHMENT"
}) { id status attemptNumber }
}
query {
inboundEmailParseAttempts(inboundEmailId: "email-uuid") {
id status attemptNumber inputSource error createdAt
}
}
Parse attempts are auto-created by the consumer for each parse run; manual creation through the GraphQL API is supported for retry tooling.
Sender authentication
Inbound delivery doesn't require a particular sender — anyone can email a JD or a CV in. SPF/DKIM/DMARC checks happen on the SES side; failures are recorded in the raw RFC822 headers but the platform doesn't reject on them. Dropping malicious mail at the gate is owned by the SES inbound ScanEnabled=true config (spam + virus verdict) and the bucket-side aws:SourceAccount confused-deputy guard.
If you need to block a particular sender, add a SES receipt-rule predecessor with a BounceAction — currently we don't.
Operational runbook
For provisioning a new env, IAM updates, the IMAP cutover sequence, sentinel testing, and the slug backfill SQL, see Inbound email — prod cutover & runbook.
Related
- AWS-Native Migration — context for why the IMAP path went away
- Mail & Email — code-side details on
SqsInboundEmailConsumer - CV Extraction — what the CV path's classifier + LLM actually do
- Draft CV Inbox — where a
CV_CREATEDdraft is reviewed, promoted, or declined - Talents — what a promoted draft becomes