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 creates the matching Job (with parsed fields) or User + Talent + CV records. As of May 2026 the path is event-driven (SES → S3 → SQS → background-tasks); the legacy IMAP poller is off in production.

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 → creates a User + Talent + CV

{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: creates a User (isOnboarding=false, isActive=false, isVerified=false), a Talent in the company's pool with source=CV_UPLOAD, and a CV linked to both. The 11 structured CV tables (CvProfile, CvWorkExperience, …) get populated via save_parsed_schema.
  7. Status update — the InboundEmail.processingStatus walks through RECEIVEDROUTEDPARSINGJOB_CREATED / CV_CREATED, or terminates in FAILED / IGNORED with a processingErrorCode.

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; User + Talent + CV chain linked via extractedUserId / extractedTalentId
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.

The first PDF or DOCX is the parse input. If multiple attachments, the rest are stored but ignored by the classifier — the operational pattern is "one CV per email" and "one JD per email".

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.

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.

  • 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
  • Talents — what happens to the User/Talent/CV chain after CV_CREATED