Skip to main content

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.

CV emails now land as drafts, not talents

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:

PatternPurpose
jobs+{slug}@inbound.aiqlick.comJob description ingestion → creates a Job
cv+{slug}@inbound.aiqlick.comCV 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 apexinbound.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.

info

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

  1. SES receives the email (TLS optional, virus + spam scan enabled) and matches a receipt rule on the recipient base local-part (cv@inbound.aiqlick.com or jobs@inbound.aiqlick.com — the +slug suffix is preserved in the To: header but doesn't affect rule matching).
  2. SES writes the raw RFC822 to S3 under cvs/{message-id} or jobs/{message-id}.
  3. EventBridge fires on ObjectCreated and routes to the matching SQS queue (aiqlick-inbound-jobs-{env} or aiqlick-inbound-cvs-{env}).
  4. SqsInboundEmailConsumer in background-tasks downloads the raw bytes from S3, parses MIME headers, extracts the company tag from To: / Cc: / Delivered-To:, and persists an InboundEmail row keyed on a synthetic imapUid = sha256(s3-key)[:8] so SQS redeliveries dedupe via the (mailboxAddress, imapFolder, imapUid) unique constraint.
  5. Attachment storage — every MIME attachment is uploaded to the regular S3 uploads bucket and recorded as an InboundEmailAttachment row; the consumer reuses the IMAP worker's _store_attachment helper so behaviour matches the legacy path.
  6. Classifier + extraction — the consumer's on_persisted hook fires the parse:
    • Jobs path — runs JobParsingService against the body text or the first attachment. The classifier rejects non-JD content with processingErrorCode = DOCUMENT_TYPE_MISMATCH (e.g., a CV mistakenly emailed to jobs+slug@).
    • CV path — runs CVExtractionService (OCR → LLM extraction). Same classifier rejects non-CV content. On success it saves a draft: a CV row with userId/talentId null, plus the 11 structured CV tables (CvProfile, CvWorkExperience, …) via save_parsed_schema and the extracted skills. No User or Talent is 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).
  7. Status update — the InboundEmail.processingStatus walks through RECEIVEDROUTEDPARSINGJOB_CREATED / CV_CREATED, or terminates in FAILED / IGNORED with a processingErrorCode. For CV drafts created inside the app (bulk import, AI agent), the container row is synthetic: imapFolder = "INTAKE" and imapUid = NULL.

Processing statuses

StatusMeaning
RECEIVEDPersisted from S3, no company tag yet
ROUTEDCompany resolved successfully via slug
IGNOREDRecipient had no +tag (missing_company_tag) or attachment-required path got none (no_cv_attachment)
PARSINGClassifier + LLM extraction in flight
JOB_CREATEDJobs path completed; Job row linked via jobId
CV_CREATEDCV 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.
FAILEDcompanyNotFound, 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/.docx attachments (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 with imap_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:

CaseStatusNotes
Plain text JD body, no attachmentJOB_CREATEDBody text is enough if it's clearly JD-shaped (title, requirements, location).
HTML-only bodyJOB_CREATEDBody text is extracted from HTML before classification.
Attachment is a release-notes doc / generic PDFFAILED + DOCUMENT_TYPE_MISMATCHClassifier returned OTHER or BUG_REPORT etc.
Attachment is a near-empty / corrupt PDFFAILED + CV_EXTRACTION_FAILED (CV path)OCR returned only a few characters.
Plus-tag refers to a slug that doesn't exist in CompanyFAILED + company_not_foundCommon 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

ModelPurpose
InboundMailboxSynthetic row per env satisfying the InboundEmail FK
InboundEmailEmail content, routing metadata, processing state. Also stores rawMimeBucket + rawMimeKey pointing at the original S3 object for re-processing.
InboundEmailAttachmentFile metadata with S3 keys and SHA-256 hashes
InboundEmailParseAttemptPer-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.