Home / admin / email-service-inbound
email-service-inbound
Inbound email processing in Salesforce via Email Services + the `Messaging.InboundEmailHandler` Apex interface.
Open the skill folder on GitHub
Inbound Email Service
Salesforce can accept inbound email and run Apex against each message. The mechanism: an Email Service (org-level configuration) maps one or more Email Service Addresses (the local-part + Salesforce-supplied domain) to a class implementing Messaging.InboundEmailHandler. Apex receives the parsed email, returns a Messaging.InboundEmailResult, and the platform responds (delivery success, error reply, drop) accordingly.
The classic alternative is Email-to-Case — a built-in service that creates a Case from each email, with extensive admin configuration (auto-response, routing, threading, contact lookup). Email-to-Case is the right answer for case creation; custom email service is the right answer for everything else (file uploads, custom-object creation, audit logging, complex routing).
What this skill is NOT. Outbound email — admin/email-templates-and-alerts. Email-to-Case-specific configuration — admin/email-to-case-configuration. This skill is the custom-handler path.
Before Starting
- Decide custom service vs Email-to-Case. Case creation? E2C
is built; don't reinvent. Anything else? Custom service.
- Plan the running user. The handler runs as the user
configured on each Email Services Address (
runAsUser). Their permissions determine what the handler can do. Use a dedicated integration user. - Plan the accept-from policy. Anonymous public addresses
receive spam; consider authorized-senders allow-listing or rate limiting.
- Plan attachment handling. Salesforce has limits (~25 MB combined email
size, max attachment size, total org file storage). Decide store vs discard before traffic ramps.
Questions to Ask Before Configuring
Each answer sets a specific, Required element on the EmailServicesFunction record. Skipping one does not leave a blank — it leaves a default whose failure mode nobody chose.
| Ask | Why it matters | What a good answer adds |
|---|---|---|
| "If we are switched off during a cutover, or the org hits its daily inbound limit — hold the mail, bounce it, or drop it?" | functionInactiveAction and overLimitAction are Required; Discard loses messages with no notice to anyone | Two explicit enum values instead of UseSystemDefault, and a documented data-loss position |
| "Who gets paged when a message fails to process — the sender, or us?" | Without isErrorRoutingEnabled + errorRoutingAddress, notifications go to the sender and intake breaks silently | A monitored internal address, and a rule that failure text sent outward stays generic |
| "Which username does each address run as, and what can that user see?" | runAsUser is Required per address, so two addresses on one service can run the same Apex with different visibility | One named integration user per address plus its permission set |
| "Which senders are legitimate, and must we verify the sending server?" | authorizedSenders filters before Apex; isAuthenticationRequired turns on SPF / SenderId / DomainKeys checks | An allow-list, and a decision on authenticationFailureAction for unverified mail |
| "What do we need from attachments — nothing, filenames only, text, binary, or everything?" | attachmentOption filters at the platform layer before the bytes reach the heap | The right enum, plus size / count / MIME caps for whatever survives it |
| "How many inbound messages a day, across every email service and On-Demand Email-to-Case?" | The daily limit is org-wide (user licences x 1,000, max 1,000,000), not per service | A capacity number checked against licence count before the service exists |
| "Who owns re-pointing the forwarding rule after each sandbox refresh?" | The routing domain is generated per address per org and cannot be promoted from sandbox | A named owner and the address stored in Custom Metadata, not in Apex |
What a proper configuration adds over just writing the handler: the mail that arrives while you are deploying, over quota, or failing is held rather than lost, the people who need to know are told, and the address survives a sandbox refresh as a data edit rather than a re-deployment.
Core Concepts
Email Service vs Email Services Address
- Email Service (org-level) — the configuration: running
user, max retention, accept-from policy, accept attachments, error response template.
- Email Services Address (per address) — the actual local-part
- Salesforce-supplied subdomain. One Email Service can have
many Addresses (e.g.
quotes-prod@...,quotes-dev@...,quotes-eu@...), all routing to the same handler.
- Salesforce-supplied subdomain. One Email Service can have
The handler doesn't see which Address received the email directly — it sees the recipient in email.toAddresses. Branch on the recipient if you need per-address logic.
EmailServicesAddress is not a separate deployable type: addresses are nested inside the EmailServicesFunction file as repeated <emailServicesAddresses> elements. The fields that decide behaviour:
| Element | Level | Required | What it decides |
|---|---|---|---|
apexClass | service | yes | The handler. Must exist in the target org, or ship in the same payload |
attachmentOption | service | yes | None / NoContent / TextOnly / BinaryOnly / All — platform-level attachment filter |
isTextAttachmentsAsBinary | service | no | true delivers text attachments as BinaryAttachment (Blob), not TextAttachment (String) |
authorizedSenders | service and address | no | Sender allow-list. Blank accepts anyone |
authorizationFailureAction | service | yes | What an unlisted sender gets — even when the allow-list was set on the address |
isAuthenticationRequired / authenticationFailureAction | service | no / yes | SPF, SenderId and DomainKeys verification of the sending server |
isErrorRoutingEnabled / errorRoutingAddress | service | no | Send failure notifications to an internal address instead of the sender |
functionInactiveAction | service | yes | Mail arriving while the service is off: UseSystemDefault / Bounce / Discard / Requeue |
overLimitAction | service | yes | Mail arriving after the org's daily limit: same four values |
runAsUser | address | yes | Username whose permissions the handler assumes for mail to that address |
localPart | address | yes | The string before the @. The domain is system-generated and read-only |
Deployable XML, the handler, the test class and the post-deploy verification SOQL: references/metadata-examples.md.
Messaging.InboundEmailHandler interface
public with sharing class IncomingQuoteHandler implements Messaging.InboundEmailHandler {
public Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email,
Messaging.InboundEnvelope envelope
) {
Messaging.InboundEmailResult result = new Messaging.InboundEmailResult();
try {
processQuote(email);
result.success = true;
} catch (Exception ex) {
ApplicationLogger.error('Quote email failed', ex);
result.success = false;
// Mailed to the sender: no exception text, no record Ids.
result.message = 'We could not process this request. '
+ 'Please contact support@acme.example.';
}
return result;
}
}
Three things to know:
globalis not required. The Apex Developer Guide's own samples(
CreateTaskEmailExample,unsubscribe) arepublic with sharing. Useglobalonly when the class must be visible outside its namespace — a managed package. (references/gotchas.md§ 1.)InboundEmailpayload —subject,fromAddress,fromName,toAddresses,ccAddresses,replyTo,plainTextBody,htmlBody, the matchingplainTextBodyIsTruncated/htmlBodyIsTruncatedflags,messageId,inReplyTo,references,authenticationResults,binaryAttachments,textAttachments, andheaders(InboundEmail.Header[], exposing onlynameandvalue).InboundEnvelopecarries justfromAddressandtoAddress.- Return value.
success = true→ delivery confirmed.success = false→ Salesforce rejects the email and replies to the sender withmessage.messageis sent irrespective ofsuccess, so a successful acknowledgement reply is also possible.
Email threading via In-Reply-To and References headers
Email clients thread replies by Message-Id and In-Reply-To headers. Salesforce has already parsed all three onto the object — do not iterate headers for them:
Set<String> parents = new Set<String>();
if (String.isNotBlank(email.inReplyTo)) {
parents.add(email.inReplyTo); // String
}
if (email.references != null) {
parents.addAll(email.references); // String[]
}
String mine = email.messageId; // this message's own Message-ID
references is documented as containing the parent emails' References and message IDs "and possibly the In-Reply-To fields", so it often survives a client that dropped inReplyTo. Check both, then fall back to a subject token. Store your own outbound Message-Id on the record (or use EmailMessage.MessageIdentifier, which is idLookup-enabled) so the lookup has something to match.
For threading inbound emails to existing Salesforce records:
- Email-to-Case threading — uses
[ref:...]token in subject /body. The system inserts the token in outbound replies; inbound replies preserve it; E2C extracts it and links the email to the existing case.
- Custom service threading — implement your own. Either embed
a token in your outbound emails (case-insensitive, robust against client mangling) or look up by
In-Reply-Toagainst a stored Message-Id of your previous outbound.
Attachment handling
for (Messaging.InboundEmail.BinaryAttachment att : email.binaryAttachments) {
ContentVersion cv = new ContentVersion(
Title = att.fileName,
PathOnClient = att.fileName,
VersionData = att.body,
FirstPublishLocationId = parentRecordId
);
insert cv;
}
The attachment lists are null, not empty, when nothing was sent, and attachmentOption = NoContent delivers metadata with att.body set to null — guard both before touching body. Collect the records and issue one DML statement after the loop.
Limits:
- Max email size: email services reject a message whose combined body
text, body HTML and attachments exceeds approximately 25 MB, varying with language and character set. This is a platform ceiling, not a per-service setting — there is no
maxEmailSizeelement. (references/gotchas.md§ 4.) - Heap: email services get 50 MB, not the 6 MB synchronous budget.
Do the Blob work inside the handler; hand off a
ContentVersionId, not aBlob. (references/gotchas.md§ 11.) - Daily messages: org-wide across all email services and On-Demand
Email-to-Case — number of user licences multiplied by 1,000, maximum 1,000,000. Overflow is handled by
overLimitAction. - Org-wide file storage: every saved attachment counts against the
org's File Storage allocation. Plan retention.
Email-to-Case vs custom service decision
| Need | Use |
|---|---|
| Create a Case from an email | Email-to-Case |
| Auto-response from a template | Email-to-Case (or On-Demand E2C) |
| Threading replies to existing Case | Email-to-Case with [ref:...] token |
| Create a Lead / Opportunity / Custom Object | Custom service |
| Upload a file to a record | Custom service |
| Trigger a downstream API callout | Custom service |
| Complex routing (e.g. "if subject starts with X, do Y") | Custom service (or E2C with assignment rules — depends) |
| Multi-language / encoded payloads | Custom service for full control |
Common Patterns
Pattern A — Lead-from-email-form
When to use. Marketing landing page submits a form via email to a known address; need to create a Lead from each.
public with sharing class LeadFromEmail implements Messaging.InboundEmailHandler {
public Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email, Messaging.InboundEnvelope envelope
) {
Messaging.InboundEmailResult res = new Messaging.InboundEmailResult();
try {
Lead l = new Lead(
Email = email.fromAddress,
LastName = email.fromName != null ? email.fromName : '(unknown)',
Company = parseCompanyFromBody(email.plainTextBody),
LeadSource = 'Email Form'
);
insert l;
res.success = true;
} catch (DmlException ex) {
res.success = false;
res.message = 'Could not create lead: ' + ex.getMessage();
}
return res;
}
}
Pattern B — File upload to existing record via subject token
When to use. Users email attachments to a routing address with the record Id in the subject (Upload — 0061a000007ABC).
The handler parses the Id from the subject, validates it, attaches files. Returns success / failure to the sender.
Pattern C — Anti-spam allow-list
When to use. Public-facing routing address that gets spam.
private static final Set<String> ALLOWED_DOMAINS = new Set<String>{
'acme.com', 'partner.example.com'
};
public Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email, Messaging.InboundEnvelope envelope
) {
String fromDomain = email.fromAddress.substringAfter('@').toLowerCase();
if (!ALLOWED_DOMAINS.contains(fromDomain)) {
Messaging.InboundEmailResult res = new Messaging.InboundEmailResult();
res.success = false;
res.message = 'Sender domain not authorized';
return res;
}
// ... legitimate processing ...
}
For more nuanced allow-listing, store the list in Custom Metadata or Custom Setting so admins can manage without redeploying Apex. Note the platform already does the coarse version for free: authorizedSenders on the service or the address rejects unlisted senders before Apex runs, and authorizationFailureAction (service level) decides whether they are bounced or discarded. Reach for Apex allow-listing when the rule is per-sender-plus-content, not per-domain.
Decision Guidance
| Situation | Approach | Reason |
|---|---|---|
| Create a Case from email | Email-to-Case | Built-in; threading, routing, auto-response included |
| Create any other record from email | Custom Email Service + InboundEmailHandler | E2C only creates Cases |
| Public address receives spam | Custom service with allow-list | E2C also has spam handling but per-Case |
| Need to upload files | Custom service | E2C Email Message attachments tied to Case |
| Threading replies to existing record | inReplyTo + references first, subject token as fallback | The identifiers come from the sender's client and are not always present; the token is in the visible subject |
| Inbound volume approaching (user licences x 1,000) | Model the org-wide daily budget before adding the service | The limit is shared across all email services and On-Demand Email-to-Case; overflow is handled by overLimitAction |
| Multi-language / RTL / encoded text attachment | Custom handler reading TextAttachment.charset | The body is re-encoded as UTF-8 for Apex; the original character set survives only on that property |
| Inbound email triggers a callout | Custom service + Platform Event for async work | Don't do the callout in the handler synchronously |
| Anonymous public access undesirable | authorizedSenders + authorizationFailureAction | Platform-level allow-list; rejects before Apex runs. Add isAuthenticationRequired to verify the sending server via SPF / SenderId / DomainKeys |
Recommended Workflow
- Route the requirement. Case creation goes to
admin/email-to-case-configuration— stop here. Anything else continues. Run the## Questions to Ask Before Configuringtable and record the answers intemplates/email-service-inbound-template.md; each row names the element it sets. - Provision the running user(s). One integration user per address,
with a named permission set.
runAsUseris a username, per address, and Required — an address without one fails the deploy. - Write the handler against the shape in
references/metadata-examples.md§ The handler class:public with sharing, thread oninReplyTo/referenceswith a subject-token fallback, null-guard both attachment lists, collect then insert once, and keep exception detail out ofInboundEmailResult.message. - Write the test class from
references/metadata-examples.md§ Thetest class —
new Messaging.InboundEmail()andnew Messaging.InboundEnvelope()build the payload directly. Cover first email, threaded reply,binaryAttachments = null, and an attachment refused by policy. - Author the
EmailServicesFunctionXML. Copy the service block fromreferences/metadata-examples.md, then set the six Required elements deliberately —apexClass,attachmentOption,authenticationFailureAction,authorizationFailureAction,functionInactiveAction,overLimitAction— plus one nested<emailServicesAddresses>per intake population. - Check before deploying.
python3 skills/admin/email-service-inbound/scripts/check_email_service_inbound.py \ --manifest-dir force-app/main/defaultIt flags an unresolvable
apexClass, an inactive service, an address missingrunAsUser,isErrorRoutingEnabledwith noerrorRoutingAddress, an inventedmaxEmailSize, DML inside an attachment loop, a stack-trace leak and a synchronous callout. Thensf project deploy start --dry-runwith the handler test. - Read the routing address back and smoke-test. The domain part is
generated per org, so query
SELECT LocalPart, EmailDomainName, IsActive FROM EmailServicesAddress WHERE Function.FunctionName = '<name>', send a real message toLocalPart@EmailDomainName, and verify the success path, the failure reply, and thaterrorRoutingAddressreceived the failure notification. Store the address in Custom Metadata — it changes on every sandbox refresh.
Review Checklist
- [ ] Handler is
public with sharingunless it ships in a managed package. - [ ] Handler returns
InboundEmailResultwith an explicitsuccessvalue (never silently throws);messagecarries no exception detail. - [ ] Both attachment lists null-guarded;
att.bodynull-guarded; one DML statement after the loop, not inside it. - [ ]
runAsUserset on every address, each a documented integration user with a named permission set. - [ ]
attachmentOptionchosen deliberately; size / count / MIME caps in Apex on whatever it lets through. - [ ]
authorizedSenders+authorizationFailureActionset for public-facing addresses. - [ ]
isErrorRoutingEnabledtrue with a monitorederrorRoutingAddress. - [ ]
functionInactiveActionandoverLimitActionare notUseSystemDefault. - [ ] Threading reads
inReplyToandreferences, with a subject-token fallback. - [ ] Truncation flags (
plainTextBodyIsTruncated,htmlBodyIsTruncated) read before parsing. - [ ] Test class covers first email, threaded reply, null attachment list, and a policy-refused attachment.
- [ ] Routing address read from
EmailServicesAddress.EmailDomainName, stored in Custom Metadata, never hardcoded. - [ ]
scripts/check_email_service_inbound.py --manifest-dir …is clean.
Salesforce-Specific Gotchas
globalis a packaging decision, not an interface requirement. The guide's own samples arepublic with sharing. (§ 1.)email.headersisInboundEmail.Header[], not aMap— and the three headers you want are already parsed onto the object. (§ 2.)success = falsemails yourmessageto the sender. Never a stack trace.messageis also sent whensuccessis true. (§ 3.)- The ~25 MB ceiling is a platform limit, not a service setting. There is no
maxEmailSizeelement to raise. (§ 4.) - The address domain is generated per address per org and is read-only. Query
EmailDomainName; never hardcode. (§ 5.) runAsUseris Required per address, not per service — two addresses on one service can run the same Apex as different users. (§ 6.)inReplyTocomes from the sender's client; checkreferencestoo and keep a subject-token fallback. (§ 7.)- The daily message limit is org-wide and shared with On-Demand Email-to-Case: licences x 1,000, max 1,000,000. (§ 10.)
- Email-service Apex gets a 50 MB heap, not 6 MB — keep the Blob work in the handler. (§ 11.)
- Sandbox routing addresses cannot be copied to production, and refreshes regenerate them. (§ 12.)
authorizationFailureActionis service-level even whenauthorizedSenderswas set on the address. (§ 13.)- Bodies and text attachments arrive truncated with a flag you must read before parsing. (§ 14.)
EmailServicesFunctionaccepts no*wildcard in package.xml. Name every service. (§ 16.)
Output Artifacts
| Artifact | Description |
|---|---|
Messaging.InboundEmailHandler class | The handler implementation (references/metadata-examples.md) |
| Test class | Synthetic InboundEmail / InboundEnvelope payloads; four named cases |
emailservices/<Name>.emailservices-meta.xml | The service plus its nested address records |
package.xml entries | ApexClass + EmailServicesFunction (no wildcard) |
| Running-user permission set | One per address, named in the config workbook |
| Allow-list source | Custom Metadata / Custom Setting for admin-managed sender list |
| Threading strategy | inReplyTo / references lookup plus the subject-token format |
| Routing-address record | LocalPart + EmailDomainName per org, stored in Custom Metadata |
Reference Files
| File | Read it when |
|---|---|
references/metadata-examples.md | You are writing the EmailServicesFunction XML, the handler, the test class or the package.xml, or you need the post-deploy SOQL that reveals the routing address |
references/gotchas.md | A configured service behaves unexpectedly — mail bounced or vanished, attachments missing or the wrong Apex type, an address that worked in sandbox and not in production, a global/public compile argument |
references/examples.md | You want worked before/after code: safe failure messages, threaded case lookup, Custom-Metadata allow-listing, and the two-layer attachment policy |
references/llm-anti-patterns.md | You are reviewing AI-generated inbound-email code or advice, or want the detection hints for each recurring mistake |
references/well-architected.md | You are justifying the design — pillar mapping, the tradeoffs behind one-service-many-addresses, and the official sources behind every claim here |
Supporting files: templates/email-service-inbound-template.md (work template mapping each decision to its metadata element) and scripts/check_email_service_inbound.py --manifest-dir <dir> (metadata + handler checks).
Related Skills
admin/email-to-case-configuration— when the requirement is case creation; this skill is the custom-service alternative.admin/email-templates-and-alerts— outbound email infrastructure, including the reply your handler acknowledges with.apex/apex-email-services— the Apex-side treatment of the same handler; this skill owns the service configuration.apex/apex-event-bus-subscriber— when the handler publishes a Platform Event for async downstream work.apex/dynamic-apex— when the handler needs Schema describe to create records of varying types.admin/custom-metadata-types— where the sender allow-list and the per-org routing address belong.