Home / apex / apex-email-services
apex-email-services
Use this skill when implementing inbound email processing via Apex: parsing emails sent to a Salesforce-hosted address, creating or updating records from email content, handling attachments, or configuring Email Service routing. Trigger keywords: InboundEmailHandler, email service address, handleInboundEmail, Messaging.InboundEmail, Email-to-Case alternative, process email in Apex. NOT for outbound email — use apex/apex-outbound-email-patterns. NOT for email templates or workflow email alerts — use admin/email-templates-and-alerts.
Open the skill folder on GitHub
Apex Email Services
This skill activates when a practitioner needs to receive, parse, and act on inbound emails using Apex. It covers the full lifecycle: implementing Messaging.InboundEmailHandler, configuring the Email Service address in Setup, parsing body and attachment content, handling rejection/bounce behavior, and writing testable handlers. It does NOT cover outbound email (Messaging.SingleEmailMessage), email alerts, or the declarative Email-to-Case setup.
Before Starting
Gather this context before working on anything in this domain:
- Confirm the org's user license count, not its edition — the inbound processing ceiling scales with licenses. "Email Services: Maximum Number of Email Messages Processed" is "Number of user licenses multiplied by 1,000; maximum 1,000,000", and Salesforce "limits the total number of messages that all email services combined, including On-Demand Email-to-Case, can process daily." So it is an org-wide daily pool, not a per-service-address allowance: every email service in the org, plus On-Demand Email-to-Case, draws from the same number. Ten licenses means 10,000 messages a day across all of them. Messages over the ceiling are bounced, discarded, or queued for the next day depending on each service's failure-response setting.
- Identify whether you need text parsing, HTML parsing, or binary attachment processing — these require different handler branches.
- The most common wrong assumption: practitioners expect
handleInboundEmailto run as the sender. It does not — it runs as the Email Service's configured Context User, andemail.fromAddressis unverified header data at every API version. What its database operations enforce is gated by theapiVersionin the class's.cls-meta.xml, not by the org's release: at 66.0 and below a handler declared without a sharing keyword runs in system mode, so no user permission enforcement applies unless you explicitly delegate to a helper class declaredwith sharing; at 67.0+ (Summer '26) that inverts and the bare class runswith sharingwith SOQL/DML defaulting to user mode against the Context User. Read the.cls-meta.xmlbefore assuming either. Canonical table:agents/_shared/AGENT_CONTRACT.md§ Apex security idiom by API version. - Email Services run synchronously per message. Governor limits apply to the full handler execution: 100 SOQL queries, 150 DML statements, and a heap of 50 MB — email services get their own elevated heap allocation, not the 6 MB synchronous / 12 MB asynchronous figure that applies to ordinary Apex. Size your attachment handling against 50 MB, and note that heap is consumed by transformations of a Blob (base64-encoding,
toString(), string concatenation), each of which allocates a fresh copy, not by holding the original Blob once. The largest payload that can reach the handler is capped upstream: "Email Services: Maximum Size of Email Message (Body and Attachments)" is 25 MB, measured across body text, HTML, and all attachments together. - The Email Service address must be activated in Setup. An inactive address silently drops all inbound mail.
Core Concepts
Mode 1: The InboundEmailHandler Interface
Messaging.InboundEmailHandler is the contract for all Apex inbound email processing. Your class must implement exactly one method:
global Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email,
Messaging.InboundEnvelope envelope
) { ... }
The return value matters. If you set result.success = false, Salesforce either bounces the message back to the sender or drops it silently — controlled by the Error Action setting on the Email Service configuration in Setup. Always return a populated InboundEmailResult; a null return is treated as failure.
The InboundEmail object carries the full message:
email.subject— subject lineemail.fromAddress/email.fromName— sender identityemail.plainTextBody/email.htmlBody— body variantsemail.toAddresses/email.ccAddresses— recipient arraysemail.textAttachments— list ofInboundEmail.TextAttachmentemail.binaryAttachments— list ofInboundEmail.BinaryAttachment
The InboundEnvelope carries transport-level metadata: toAddress, fromAddress, which can differ from the To: and From: headers.
Mode 2: Email Service Configuration
Each Apex handler class is associated with one or more Email Service configurations in Setup > Email Services. Each configuration generates a unique @[instance].salesforce.com address. Key settings:
| Setting | Purpose |
|---|---|
| Active | Must be checked or all mail is dropped. |
| Accept Email From | Restrict to specific sender domains or addresses; leave blank to accept all. |
| Error Action | Controls what happens when success = false — Bounce, Discard, or Requeue. |
| Apex Class | Points to your InboundEmailHandler implementation. |
| Over Email Rate Limit | Action when the daily limit is reached — Bounce, Discard, or Requeue. |
You can create multiple Email Service addresses (each with a different configuration) backed by the same Apex class to support different routing scenarios (e.g., separate addresses per product line, each stamping a different record type on created Cases).
Mode 3: Attachment Parsing
Two attachment types exist, mapped separately:
InboundEmail.TextAttachment: has.body(String),.fileName,.mimeTypeSubTypeInboundEmail.BinaryAttachment: has.body(Blob),.fileName,.mimeTypeSubType
CSV, plain text, and XML files arrive as TextAttachment when the MIME type is text-based. Images, PDFs, and binary formats arrive as BinaryAttachment. Handlers must check both lists and apply defensive null checks — either list can be null if no attachments of that type exist.
Attachments are processed synchronously inside your handler's governor limit budget. The inbound message as a whole — body plus all attachments — is capped at 25 MB, so that is the ceiling on what can arrive, against the 50 MB email-services heap allocation. Heap is consumed by transforming the payload rather than by receiving it: converting a Blob to a String (e.g., EncodingUtil.base64Encode, Blob.toString()) allocates a fresh full-size copy each time, and two or three copies of a large attachment will exhaust 50 MB. Use chunked processing, or persist the Blob and defer the heavy work to a Queueable called from within the handler — sizing that Queueable's work against the 12 MB asynchronous heap limit rather than the handler's 50 MB.
Common Patterns
Pattern: Create-or-Update Record from Inbound Email
When to use: An external system sends structured emails (e.g., order confirmations, sensor alerts) and you need to upsert Salesforce records based on parsed email content.
How it works:
- Implement
InboundEmailHandlerand parseemail.plainTextBodyoremail.subjectfor a record identifier (e.g., an order number in the subject line). - Use
SOQLto find an existing record by the extracted identifier. - Upsert or insert accordingly, populating fields from parsed email content.
- Return
result.success = trueon success, or log errors and returnfalseif parsing fails.
global class OrderEmailHandler implements Messaging.InboundEmailHandler {
global Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email,
Messaging.InboundEnvelope envelope
) {
Messaging.InboundEmailResult result = new Messaging.InboundEmailResult();
try {
String orderNum = extractOrderNumber(email.subject);
if (String.isBlank(orderNum)) {
result.success = false;
result.message = 'No order number found in subject.';
return result;
}
List<Order__c> orders = [
SELECT Id FROM Order__c WHERE OrderNumber__c = :orderNum LIMIT 1
];
Order__c order = orders.isEmpty() ? new Order__c(OrderNumber__c = orderNum) : orders[0];
order.LastEmailBody__c = email.plainTextBody;
order.LastEmailDate__c = System.now();
upsert order OrderNumber__c;
result.success = true;
} catch (Exception e) {
result.success = false;
result.message = e.getMessage();
}
return result;
}
private String extractOrderNumber(String subject) {
if (subject == null) return null;
Pattern p = Pattern.compile('ORD-\\d+');
Matcher m = p.matcher(subject);
return m.find() ? m.group() : null;
}
}
Why not simpler approaches: A Flow with Email-to-Case only creates Cases. Custom Apex is needed when you need to target arbitrary sObjects, apply complex parsing logic, or conditionally reject mail.
Pattern: Async Attachment Processing via Queueable
When to use: Emails arrive with large or multiple binary attachments that would exceed heap or CPU limits if processed synchronously.
How it works:
- In
handleInboundEmail, extract only the attachment metadata and Blob data. Store the Blob in aContentVersionrecord immediately (avoids re-processing the email). - Enqueue a
Queueablejob, passing theContentVersionId. - The Queueable performs the expensive parsing (CSV, base64 decode, PDF text extraction stubs, etc.) in a separate transaction with its own governor limits.
- Return
result.success = truefrom the handler immediately — the email is accepted, processing continues asynchronously.
Why not synchronous: The email-services heap ceiling is 50 MB, not the 6 MB synchronous / 12 MB asynchronous figure for ordinary Apex — a 10–15 MB attachment does not blow heap by arriving. What exhausts it is in-line transformation: EncodingUtil.base64Encode allocates a second, ~33%-larger copy, Blob.toString() a third, and a parse loop one per iteration.
Be precise about what deferring buys you. A Queueable runs under the ordinary 12 MB asynchronous heap limit, so moving work there lowers your heap ceiling from 50 MB — "defer it for more heap" is backwards. Defer for a fresh transaction with its own CPU and DML budget and a retry surface, and size the Queueable's work against 12 MB.
Pattern: Sender-Based Routing with Multiple Service Addresses
When to use: Different senders or subject patterns need to create different record types or trigger different workflows, but you want a single Apex class.
How it works:
- Create multiple Email Service configurations in Setup, each pointed at the same Apex class.
- Pass a routing signal via the email address itself (e.g.,
support-billing@...vssupport-tech@...) or encode routing in a Custom Setting keyed bytoAddress. - Inside
handleInboundEmail, readenvelope.toAddressto determine routing context, then branch logic accordingly.
Decision Guidance
| Situation | Recommended Approach | Reason |
|---|---|---|
| Customer email should create a Case automatically | Declarative Email-to-Case (Setup > Email-to-Case) | No code needed; native threading and routing built in |
| Email from external system should upsert a custom sObject | Apex Email Service with InboundEmailHandler | Email-to-Case only targets Cases; Apex has full DML access |
| Email has large binary attachments requiring custom parsing | Apex handler + Queueable for attachment processing | Avoids synchronous heap/CPU limits |
| Need to reject or bounce specific senders at processing time | Apex handler returning result.success = false | Email Services Error Action controls bounce/discard behavior |
| Outbound transactional emails to customers | Messaging.SingleEmailMessage or email templates + workflow | This skill covers inbound only |
| Inbound volume approaches the org's daily ceiling (user licenses × 1,000, max 1,000,000) | Platform architecture review — consider middleware or batching | The ceiling is org-wide and shared with On-Demand Email-to-Case, scales with licenses rather than edition, and cannot be raised in code |
Recommended Workflow
Step-by-step instructions for an AI agent or practitioner activating this skill:
- Gather context — confirm the org's user license count, relevant objects, and current configuration state
- Review official sources — check the references in this skill's well-architected.md before making changes
- Implement or advise — apply the patterns from Core Concepts and Common Patterns sections above
- Validate — run the skill's checker script and verify against the Review Checklist below
- Document — record any deviations from standard patterns and update the template if needed
Review Checklist
Run through these before marking work in this area complete:
- [ ]
InboundEmailHandlerclass isglobaland implements the interface correctly - [ ]
handleInboundEmailalways returns a non-nullInboundEmailResult - [ ] Email Service address is active in Setup > Email Services
- [ ]
Accept Email Fromrestriction is configured to prevent spoofing - [ ] Attachment parsing handles null
textAttachmentsandbinaryAttachmentslists defensively - [ ] Handler has a
try/catchblock and setsresult.success = falseon unexpected exceptions - [ ] Test class uses
Test.setFixedSearchResultsor constructsInboundEmailobjects directly - [ ] Daily volume estimate confirmed against the org-wide ceiling (user licenses × 1,000, max 1,000,000), counting every email service and On-Demand Email-to-Case against the same pool
- [ ] Large attachment flows enqueue a
Queueableinstead of processing synchronously - [ ] Error Action on the Email Service config is set intentionally (Bounce vs Discard)
Salesforce-Specific Gotchas
Non-obvious platform behaviors that cause real production problems:
- The Default Access Mode Is Version-Gated — at
apiVersion66.0 and below, a handler class with no sharing keyword runs in system mode: nowith sharingenforcement and no record-level access control unless you explicitly call into a helper class declaredwith sharing, so a handler that creates records with a bareinsertbypasses all sharing rules silently. At 67.0+ the bare class runswith sharingand its SOQL/DML default to user mode against the Email Service's Context User — which moves the risk onto that user's permissions rather than removing it. The gate is the class's.cls-meta.xml, not the org's release; seereferences/gotchas.mdGotcha 2. - Inactive Address Drops Mail Silently — If the Email Service address is set to Inactive in Setup, inbound emails do not bounce and do not generate errors. They are silently discarded. Practitioners regularly deploy a handler, forget to activate the service address, and spend hours debugging a "no traffic" problem.
- Both Body Fields Can Be Null — HTML-only emails leave
plainTextBodynull. Plain-text-only emails leavehtmlBodynull. Production handlers that assume one is always populated throwNullPointerExceptions on real mail. Always test both paths.
Output Artifacts
| Artifact | Description |
|---|---|
InboundEmailHandler Apex class | Global Apex class implementing Messaging.InboundEmailHandler, ready to associate with an Email Service configuration |
| Email Service configuration checklist | Verified settings in Setup > Email Services: active flag, accepted senders, error action, rate limit action |
| Attachment parsing strategy | Documented decision on sync vs async processing based on expected attachment size and volume |
| Test class | Apex test constructing Messaging.InboundEmail objects directly and asserting on DML outcomes |
Related Skills
- apex/governor-limits — Synchronous handler execution consumes the same governor limits as any Apex transaction; consult this skill when attachment or SOQL load is high.
- apex/apex-rest-services — Use when the integration partner can push HTTP instead of email; REST is preferable for high-volume structured data exchange.
- admin/email-templates-and-alerts — Use for outbound email workflows. NOT a replacement for inbound email processing.
- integration/rest-api-patterns — Consider as an alternative integration channel if volume or reliability requirements exceed what Email Services can provide.