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:
| Pattern | Purpose |
|---|---|
jobs+{slug}@inbound.aiqlick.com | Job description ingestion → creates a Job |
cv+{slug}@inbound.aiqlick.com | CV 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 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: creates aUser(isOnboarding=false,isActive=false,isVerified=false), aTalentin the company's pool withsource=CV_UPLOAD, and aCVlinked to both. The 11 structured CV tables (CvProfile,CvWorkExperience, …) get populated viasave_parsed_schema.
- Jobs path — runs
- Status update — the
InboundEmail.processingStatuswalks throughRECEIVED→ROUTED→PARSING→JOB_CREATED/CV_CREATED, or terminates inFAILED/IGNOREDwith aprocessingErrorCode.
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; User + Talent + CV chain linked via extractedUserId / extractedTalentId |
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.
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:
| 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.
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
- Talents — what happens to the User/Talent/CV chain after
CV_CREATED