Inbound Email to Actionable Task
A reusable Salesforce pattern for turning incoming email into an assigned, prioritised Task, with an Email Service, a background job and Prompt Builder templates
For solution architects, Salesforce admins and stakeholders who want to apply the pattern to their own inbox. Generalised from a working reference implementation (see the appendix). Developer detail is in the appendix.
At a glance
Requests still arrive by email: from customers, suppliers, partners and other systems. This pattern reads each one for you, links it to the record it belongs to, and leaves a clear, prioritised Task with the right person.
The problem
A shared inbox such as facilities@acme.example collects requests all day. Someone has to open each email, work out who sent it and what it's about, find the matching account, order or case, decide how urgent it is and pass it to the right person. It is slow, it depends on who is on shift, and requests get missed when the inbox is busy or the triager is away.
What the pattern does
Each email sent to a Salesforce Email Service address is matched to a record by its sender or a business key (for example Request #A-10234). A placeholder Task and the email's Files are saved at once. Then, in the background, a prompt template classifies and summarises the email, the answer is checked against allowed values, an assignment rule picks the owner, and the Task is finished: subject, priority, due date, category and owner.
These are design properties of the pattern, not measured results. Throughput and accuracy depend on your email volume, prompts and data.
Key ideas in one minute
- Match first, with rules, not AI. The sender and business key are found by pattern or lookup. If nothing matches, the email goes to the review log; the pattern never guesses which record an email belongs to.
- Fast first, smart second. The Task, the email and its Files appear on the record instantly. The AI work happens moments later in one background job (a Queueable), so a slow or failed AI call never loses an email.
- The AI suggests; the rules decide. The prompt proposes a category, summary, actions, urgency and a due date. Apex checks each value against an allowed list and falls back to safe defaults. The owner always comes from your assignment rules.
- A Flow carries files to the AI. Apex can't hand a File to a prompt template directly, so a small auto-launched Flow does that one step when attachments matter.
- Admins own the knobs. Categories, routing, priorities and due dates live in custom metadata and prompt versions, not in code.
Use it when…
- Requests arrive by email to a known address you can forward or redirect.
- Most emails can be tied to a record by sender, account or a reference number.
- The work is a follow-up for a person: a call, a check, a reply, a visit.
- You want consistent triage and a clear owner, not a full support desk.
- The categories and routing rules can be written down.
Don't use it when…
- You need queues, SLAs, entitlements and threaded replies: use Email-to-Case and Omni-Channel.
- Emails are conversations that need replies inside Salesforce.
- Volumes are very high (thousands an hour) or files are very large.
- The routing decision itself is a judgement call no rule can express.
- Regulations don't allow email content to be sent to a generative AI model.
How it works
Steps 1–3 happen the moment an email arrives; steps 4–8 run moments later in one background job.
From inbox to assigned Task
Steps 1–3 are instant and use no AI: identify, match, save. Steps 4–8 run in one background job: read files, classify, validate, assign, update. Every failure branch writes to the review log.
Each step in detail
Why a background job, and why a Flow? An Email Service handler should finish quickly and must not lose the email if an AI call is slow or fails, so it only saves records and hands over. The Queueable makes every AI call before it writes anything, then updates the Task once, which keeps callouts and DML in a safe order. The Flow exists because the Apex route to prompt templates (ConnectApi) rejects File inputs, while the Flow runtime accepts a File natively.
What the Task looks like when it's finished
Subject: [Repair] Broken door closer, Level 3 east stairwell — Request #A-10234
Owner: Priya Shah (Facilities, North region) · Priority: High · Due: 5 Nov 2026 · Category: Repair · Related to: ACME Facilities · Name: Jordan Lee
Description, top to bottom: key, sender, received time and subject; SUMMARY; ACTIONS (one bullet each); an ATTACHMENT block for each file; any VALIDATION notes (e.g. “category defaulted”); then the ORIGINAL EMAIL (first 2,000 characters).
Example values are illustrative.
What gets created
For each matched email the pattern adds records to an existing related record. It doesn't create Accounts, Cases or Orders: the record that owns the key has to be there first.
| Record | What it holds | Where it's linked |
|---|---|---|
| Task (one per email) | Placeholder: type Email, status Not Started, priority Normal. Finished by the background job with subject, description, priority, due date, category and owner. | Related To (WhatId) = matched record; Name (WhoId) = sender's Contact |
| Files (one per attachment) | Every non-empty attachment under its original file name, as a ContentVersion. | The related record and the Task |
| Email record (optional) | An EmailMessage with the full body, so the email shows in the activity timeline as an email. Recommended; the reference build didn't create one. | The related record (RelatedToId) and the Task (ActivityId) |
| Unmatched / Review log | Only when the email can't be used, or the AI or routing needed a fallback: subject, sender, reason, details, body (up to 32,000 characters), received time and a link to the Task if one exists. | Its own object and tab; optional lookup to the Task |
The Task, field by field
| Field | Set from | Fallback |
|---|---|---|
| Subject | [Category] + a one-line summary + the key, up to 255 characters | Email subject, trimmed |
| Description | Summary, actions, file blocks, validation notes, original email | Original email only, with “manual review” note |
| Priority | URGENCY mapped to High / Normal / Low by the priority table | Normal (or High if the AI failed, so a person looks) |
| ActivityDate (due) | Received date + DUE_IN_DAYS, capped by the category's maximum, skipping weekends | The category's default days, else 2 business days |
| OwnerId | First matching routing rule | The default owner user |
| Category__c (custom picklist) | Validated CATEGORY | General |
| WhatId / WhoId | Matched record / sender's Contact (step 3, not AI) | WhoId left blank |
| Needs_Review__c (optional checkbox) | Ticked when any default was used | — |
Reasons on a review log entry
| Reason | What happened | What to do |
|---|---|---|
| NO_KEY | No business key in the subject or body and the sender isn't known. | File by hand; consider a new key pattern. |
| NO_MATCH | A key was found, but no record has it. The key is in Details. | Fix the record's key and resend, or file by hand. |
| AI_UNAVAILABLE | The prompt call failed or returned nothing. The Task exists with defaults. | Read the Task; check Einstein setup. |
| INVALID_AI_OUTPUT | A value was outside the allowed list. Defaults were used. | Tune the prompt or add the category. |
| NO_ROUTING_RULE | No rule matched, or the target user is inactive. Default owner used. | Add or fix a routing row. |
| PROCESSING_ERROR | Saving the Task or Files failed. Details holds the error. | Tell your admin. |
Where to see it in Salesforce
The everyday checks, step by step. Start from the App Launcher (the 9-dot grid, top left) or Setup (the gear, top right).
A. My new Tasks
Assignees- Sort by Due Date, then Priority. Add the Category column to the list view.
- Read SUMMARY and ACTIONS, open the Related To record, and mark the Task Completed when done. Not yours? Reassign it and tell your admin.
B. The email on its related record
Everyone- Find the record by its key (e.g. search A-10234).
- On the Activity timeline, open the newest Task of type Email (and the Email, if you save one).
- Open Related › Files to preview attachments; the same Files are on the Task.
C. The review list for unmatched emails
Triage lead- Show Subject, From Address, Reason, Details, Received Date and Task. Sort newest first.
- Open a row, decide where it belongs, act, then set its status to Resolved.
- Review the counts by Reason weekly: they tell you which rule or prompt to improve.
D. Admin setup
Admins- Points to your handler class, is Active, accepts the attachments you need, and has an active address with the right Context User.
- Each template has an Active version (templates deployed without one arrive inactive). The Flow is Active, variable names unchanged.
- Edit routing rows, categories, priorities and due-date defaults here. Changes apply to the next email, no deployment needed.
- A Task stuck on “processing…”? Find the Queueable job at the time the email arrived and read its status and error.
Configuration and how to adapt it
The pattern is the same everywhere; seven decisions make it yours. Work through them in order: each one feeds the next.
Make it yours: checklist. ☐ Business key ☐ Matching rule ☐ Categories ☐ Priority and due dates ☐ Prompts ☐ Assignment rules ☐ Address and permissions
1. Choose the business key
The key is what ties an email to exactly one record. Pick the most reliable thing your senders already include.
- A reference number in the subject or body: request, ticket, order, invoice, claim. Best when senders reply to something you sent them.
- The sender's email address → Contact → Account. Works for known customers, but one person can belong to several records.
- The sender's domain → Account. A fallback only: shared domains (gmail.com) must be excluded.
Store the key on the record in a dedicated, indexed field (ideally External ID or Unique), in one normalised format, before emails arrive.
2. Write the matching rule
Decide the order of checks and what wins on a tie. A typical rule: key in subject → key in body → sender's Contact → review log.
- Make the regular expression specific: (?i)\bRequest\s*#?\s*(A-\d{5})\b beats “any 7 digits”, which also matches phone and PO numbers.
- Normalise before matching: strip prefixes, suffixes and spaces (#A-10234 → A-10234).
- On more than one match, pick the newest open record and say so in the description, or send it to review. Never pick at random.
3. Define the categories
Keep the list short (5–10), mutually exclusive and named in the business's words. Each category drives routing, priority and due date. Always include a catch-all.
Example for ACME Facilities: Repair Cleaning Access & Keys Safety Booking Billing General
4. Choose priority and due-date rules
Each category gets a default priority and due date. The AI's URGENCY can adjust them; bound the due date per category so the AI can't set something unreasonable.
| Category | Default priority | Default due (business days) | Max due | AI may raise to High? |
|---|---|---|---|---|
| Safety | High | 0 (today) | 1 | Always High |
| Repair | Normal | 2 | 10 | Yes |
| Billing | Normal | 5 | 15 | No |
| General | Normal | 2 | 10 | Yes |
5. Write the prompts
One classify template (Flex, text inputs) and, if needed, one file template (Flex, one File input, called only by the Flow). Ask for a strict, line-based answer that Apex can parse. A skeleton, with placeholders in [BRACKETS]:
You triage inbound email for [TEAM NAME] at [COMPANY].
The email relates to: {!$Input:recordName} ([RECORD TYPE]).
From: {!$Input:emailSender}
Subject: {!$Input:emailSubject}
Body (may be truncated): {!$Input:emailBody}
Attachment summaries: {!$Input:fileSummaries}
Answer with exactly these five lines and nothing else:
CATEGORY: one of [Repair | Cleaning | Access & Keys | Safety | Booking | Billing | General]
SUMMARY: 1-2 sentences: who wants what, where, by when.
ACTIONS: semicolon-separated, specific next steps for the assignee.
URGENCY: one of [High | Normal | Low]. High only if [YOUR URGENCY RULES,
e.g. safety risk, building access blocked, a deadline within 48 hours].
DUE_IN_DAYS: a whole number from 0 to [MAX], or DEFAULT if no date is implied.
Rules: never invent names, numbers or dates. If unsure, use General / Normal / DEFAULT.
Save, test in Prompt Builder with real (anonymised) emails, then activate the version. Keep the field names exactly as the parser expects.
6. Define the assignment rules table
A custom metadata type, e.g. Task_Routing_Rule__mdt, read top-down by Order; the first row whose filled-in columns all match wins. Blank means “any”.
| Order | Category | Region | Record type | Assign to | Target |
|---|---|---|---|---|---|
| 10 | Safety | User | safety.lead@acme.example | ||
| 20 | Repair | North | User | priya.shah@acme.example | |
| 30 | Repair | Round robin | Group: Facilities_Techs | ||
| 40 | Billing | Record owner | (the Account owner) | ||
| 99 | Default | triage.lead@acme.example |
- Record owner: the related record's OwnerId, if active. Default: always keep one catch-all row with an active user.
- Round robin: the next active member of a public group; store the last index in a custom setting or a small counter record.
- Store users by username, not by Id, so the table survives sandbox refreshes.
⚠ Tasks can't be owned by queues. A Task's owner must be a user. For a team inbox: assign to a designated triage user, use round robin within a group, or create a Case or custom “Request” record owned by a queue and routed with Omni-Channel, with the Task as the follow-up.
7. Set the email service address and permissions
- Create the Email Service with your handler class, the attachment types you need and a clear prefix (e.g. facilities). Forward facilities@acme.example to the generated address; restrict Accept Email From if senders are known.
- Set the Context User to a dedicated integration user, and give it a permission set: create/edit Task, create Files, create the review log, read the related objects and key fields, run the Flow, and use Prompt Builder templates.
- Give assignees read access to the related records, or they'll own Tasks they can't follow.
Testing
Test in three layers: fast unit tests with a fake AI answer, one bulk test, and end-to-end scenarios that send real emails. The reference build showed why the first layer matters: its tests drifted away from the code and stopped proving anything.
Recommended automated tests
| Test class | What it checks | How |
|---|---|---|
| EmailHandler test | Key found in subject / body; normalisation; no key → NO_KEY; no record → NO_MATCH; Task and Files created and linked; sender resolved from forwarding headers; the job is enqueued. | Build a Messaging.InboundEmail in the test; call handleInboundEmail |
| Queueable test | Parser reads all five lines; invalid category → default + flag; AI failure → AI_UNAVAILABLE and High; each routing type; inactive user → default owner; due date capped and skips weekends. | A mock prompt result injected through a test hook; Test.startTest/stopTest runs the job |
| Bulk test | 200 emails in one transaction, mixed keys; no governor limit errors; one Task per email. | Queries outside loops; one enqueue per email or a batched job |
| Routing test | Rule order, blank = any, first match wins. | Read rules through a selector you can stub, since custom metadata can't be inserted in tests |
Lessons from the reference build. Its handler test set up records on one field while the code matched on another, so the “matched” tests found nothing; its job test called a method that had been removed and stopped compiling. Keep the match field, method signatures and answer format in one place (constants or a small config class) that tests use too, and run the tests on every change.
Prompt templates can't be called from tests, so always route the AI call through a single method with a mock hook. Salesforce needs 75% coverage to deploy Apex to production; aim for every branch on the flowchart.
End-to-end test: send it emails
Prepare records
Create a test Account “ACME Facilities” with a request record keyed A-10234, a Contact jordan.lee@customer.example, and routing rows for Repair and Default.
Send the emails
Send each scenario below to the Email Service address, or to the business inbox if forwarding is set up, so the forward path is tested too.
Check the results
Refresh straight away, then again after a minute. Use paths A–C in section 4.
Happy path
Task assignedNo key, unknown sender
NO_KEYKey with no matching record
NO_MATCHAttachment carries the detail
File block on the TaskAmbiguous intent
Defaults + review flagAssignment fallback
NO_ROUTING_RULEAlso worth running: an HTML-only email, a forwarded email (sender in the header), a duplicate of scenario A, and an email with ten attachments, to see your own limits.
Known limits and design considerations
What to plan for before relying on it. Most were met while building the reference implementation; the rest are platform limits every build will hit.
Prompt templates can't take Files from Apex
The ConnectApi route rejects File inputs. Pass the ContentDocument Id to an auto-launched Flow, which hands the File to the prompt. Keep the Flow active and its variable names fixed; check the file template has an active version after every deploy.
Callout and Queueable limits
Each prompt call is a callout-like request with its own time and count limits per transaction. One file call per attachment adds up. Cap the number of files analysed, chain jobs for the rest, and keep all calls before any DML. The Email Service itself can enqueue only so many jobs per transaction.
Email size and attachments
Email Services cap the total message size (roughly 25 MB including attachments) and can be set to drop attachments or text parts. Very large files may also exceed what a prompt can read. Truncate the body sent to the AI (e.g. 3,000 characters) and keep the full text in the email record.
The Email Service runs as one user
Everything is created by the address's Context User, so it needs a permission set for every object, field, Flow and template involved. If that user is frozen or loses a licence, processing stops. Use a dedicated integration user.
Tasks can't be owned by queues
Pick a user (named, record owner, round robin or default). For true team queues, SLAs or Omni-Channel, route a Case or custom object instead and keep the Task as the follow-up.
AI output varies
The same email can get a slightly different summary or urgency. Validate every value, use defaults, and never let free text from the AI become an Id, a field name or an owner. Re-test the prompt after every model or version change.
Duplicates and threads
Senders reply-all, forward twice or resend. Store the Message-ID (and In-Reply-To) and skip, or append to the existing Task, when it's seen before. Decide whether a reply on a closed Task reopens it or creates a new one.
HTML-only and odd formats
Some mail has no plain-text body. Fall back to the HTML body with tags stripped. Signatures, disclaimers and long quoted threads confuse the AI: cut at the first quoted reply before sending.
PII and compliance
Email bodies and files go to a generative model through the Einstein Trust Layer. Confirm data masking, retention and your industry rules (health, finance, children's data) before enabling, and exclude categories of email that must not be processed.
Einstein request costs
Every prompt call consumes Einstein requests (credits). Cost scales with emails × (1 + files). Filter out auto-replies and newsletters before the AI step, and skip file analysis when it doesn't change the result.
Safety & rollback
Designed so that the worst case is “the email is on the record, logged, and a person reads it”.
Nothing is lost
Every email ends as a Task or a review log row with its body and reason. The handler always reports success to the mail server, so senders don't get bounces and nothing loops.
The AI never assigns outside the rules
The AI only proposes a category and urgency, both checked against allowed lists. The owner always comes from the routing table or the default owner, never from AI text.
Matching is deterministic
Which record an email belongs to is decided by your key and lookup, before any AI runs. No match means review, never a guess.
It only adds
It creates a Task, Files and log rows, and updates only the Task it created. It never changes the related record's own fields.
How to switch it off
- Stop all processing: Setup › Email Services › your service › untick Active (on the service or its address). Set what happens to mail sent while it's off (bounce, discard or requeue), or point the inbox forwarding back to people first.
- Keep Tasks, stop the AI: build in a switch, e.g. a custom setting AI_Enabled__c. When off, the job skips the prompts and uses defaults, so Tasks still arrive and get routed.
- Change routing at once: edit or reorder rows in the routing custom metadata. No deployment needed.
- Go back a prompt version: activate the earlier version in Prompt Builder. Test it first if the answer format changed.
Switching off doesn't undo anything: Tasks, Files and log rows already written stay as they are.
Before production
- All flowchart branches covered by unit tests with a mocked prompt; a bulk test passes; coverage over 75%.
- Scenarios A–F run in a sandbox with real, anonymised emails from each sender type.
- The key field exists, is indexed and is filled in on every record before its emails arrive.
- Prompt templates and the Flow are active in the target org (check after deploying).
- Routing table reviewed by the business; default owner is an active user; assignees can see the related records.
- Context User has its permission set; Einstein is enabled; the request budget is agreed.
- Duplicate handling (Message-ID) and the AI switch are in place.
- Privacy review done; a named owner checks the review list daily for the first weeks.
Appendix: technical reference
For admins and developers. Names below are a suggested blueprint; rename to your conventions, then keep the names in one constants class used by code and tests.
Components
| Component | Type | Purpose |
|---|---|---|
InboundEmailTaskHandler | Apex (Messaging.InboundEmailHandler) | Resolve sender; extract and normalise the key; find the record; placeholder Task; ContentVersion per attachment with ContentDocumentLinks to record and Task; optional EmailMessage; enqueue the job; log unmatched; always return success |
InboundEmailTaskJob | Apex (Queueable, Database.AllowsCallouts) | Run the attachment Flow per File; call the classify prompt; validate; resolve owner; one Task update; write review rows for fallbacks |
InboundEmailUtils | Apex | resolveOriginalSender, extractKey, invokePromptTemplate (via ConnectApi.EinsteinLLM.generateMessagesForPromptTemplate) with a mockResult test hook, parseResponse, validate, logReview |
TaskRouter | Apex | Reads routing rules through a stubbable selector; record owner, named user, round robin, default; checks the user is active |
Analyze_Email_Attachment | Auto-launched Flow | inputContentDocumentId → Get ContentDocument → Invoke Prompt Template → outputAnalysisText |
Inbound_Email_Classify | Prompt template (Flex) | Text inputs: emailSubject, emailBody, emailSender, recordName, fileSummaries; five-line answer |
Inbound_Email_File_Summary | Prompt template (Flex) | One input of type File (ContentDocument); called only by the Flow |
Inbound_Email_Review__c + tab | Custom object | Review log: Subject, From, Reason (picklist), Details, Body (long text), Received, Message-ID, Task lookup, Status |
Task_Routing_Rule__mdt | Custom metadata type | Order, Category, Region, Record Type, Assign To, Target, Active |
Task_Category_Setting__mdt | Custom metadata type | Category, default priority, default and max due days, may-raise-to-High |
Task.Category__c, Needs_Review__c | Custom fields | Validated category; fallback flag |
Inbound_Email_Task_Integration | Permission set | For the Email Service Context User |
*Test classes | Apex tests | Handler, job, router, bulk (section 6) |
| Email Service | Setup configuration | Handler class, attachments accepted, address prefix, Context User, Accept Email From |
Reference implementation
Document 811
The Document 811 solution (org “agent2”) applies this pattern to utility locate notices. It matches each email to a Work Order by a 7+ digit 811 ticket number found in the subject or body, saves the PDF tickets as Files, and in a Queueable calls an email prompt and, through an auto-launched Flow, an attachment prompt per PDF. The Task's priority is High when action is required. It doesn't yet include the validation, routing table, email record or duplicate handling described here; those are the generalisations this pattern adds. Its own solution guide covers it in detail.
Glossary
| Term | Meaning |
|---|---|
| Email Service | A Salesforce feature that gives you a generated email address and runs an Apex class for every message it receives. |
| Business key | The value that ties an email to one record: a reference number, the sender's email or domain. |
| Related record | The record the Task is linked to through Related To (WhatId), e.g. an Account, Case or Order. |
| Queueable | An Apex background job. Here it does all the AI work after the email is saved. |
| Prompt template | A reusable instruction for a generative AI model, built and versioned in Prompt Builder. |
| Auto-launched Flow | A Flow with no screen or trigger, started from Apex. Here it passes a File to a prompt template. |
| Custom metadata | Admin-editable configuration records (__mdt) that deploy with the code and can be changed without a deployment. |
| Round robin | Assigning work to members of a group in turn. |
| Context User | The user an Email Service address runs as; it creates every record. |
| Einstein Trust Layer | Salesforce's controls (masking, zero data retention, audit) between your data and the AI model. |
| Einstein requests | The usage unit consumed by each generative AI call. |
