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.
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:
global is not required. The Apex Developer Guide's own samples
(CreateTaskEmailExample, unsubscribe) are public with sharing.
Use global only when the class must be visible outside its namespace
— a managed package. (references/gotchas.md § 1.)
InboundEmail payload — subject, fromAddress, fromName,
toAddresses, ccAddresses, replyTo, plainTextBody, htmlBody,
the matching plainTextBodyIsTruncated / htmlBodyIsTruncated flags,
messageId, inReplyTo, references, authenticationResults,
binaryAttachments, textAttachments, and headers
(InboundEmail.Header[], exposing only name and value).
InboundEnvelope carries just fromAddress and toAddress.
- Return value.
success = true → delivery confirmed.
success = false → Salesforce rejects the email and replies to the
sender with message. message is sent irrespective of success, 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-To against 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
maxEmailSize element.
(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
ContentVersion Id, not
a Blob. (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 Configuring table and
record the answers in templates/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.
runAsUser is 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 on inReplyTo / references with a
subject-token fallback, null-guard both attachment lists, collect then
insert once, and keep exception detail out of
InboundEmailResult.message.
- Write the test class from
references/metadata-examples.md § The
test class — new Messaging.InboundEmail() and
new Messaging.InboundEnvelope() build the payload directly. Cover
first email, threaded reply, binaryAttachments = null, and an
attachment refused by policy.
- Author the
EmailServicesFunction XML. Copy the service block from
references/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/default
It flags an unresolvable apexClass, an inactive service, an address
missing runAsUser, isErrorRoutingEnabled with no
errorRoutingAddress, an invented maxEmailSize, DML inside an
attachment loop, a stack-trace leak and a synchronous callout. Then
sf project deploy start --dry-run with 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 to
LocalPart@EmailDomainName, and verify the success path, the
failure reply, and that errorRoutingAddress received the failure
notification. Store the address in Custom Metadata — it changes on
every sandbox refresh.
Review Checklist
Salesforce-Specific Gotchas
global is a packaging decision, not an interface requirement. The guide's own samples are public with sharing. (§ 1.)
email.headers is InboundEmail.Header[], not a Map — and the three headers you want are already parsed onto the object. (§ 2.)
success = false mails your message to the sender. Never a stack trace. message is also sent when success is true. (§ 3.)
- The ~25 MB ceiling is a platform limit, not a service setting. There is no
maxEmailSize element to raise. (§ 4.)
- The address domain is generated per address per org and is read-only. Query
EmailDomainName; never hardcode. (§ 5.)
runAsUser is Required per address, not per service — two addresses on one service can run the same Apex as different users. (§ 6.)
inReplyTo comes from the sender's client; check references too 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.)
authorizationFailureAction is service-level even when authorizedSenders was set on the address. (§ 13.)
- Bodies and text attachments arrive truncated with a flag you must read before parsing. (§ 14.)
EmailServicesFunction accepts 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.