SfSkills Domains Docs GitHub

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


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.

AskWhy it mattersWhat 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 anyoneTwo 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 silentlyA 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 visibilityOne 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 checksAn 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 heapThe 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 serviceA 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 sandboxA 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

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:

ElementLevelRequiredWhat it decides
apexClassserviceyesThe handler. Must exist in the target org, or ship in the same payload
attachmentOptionserviceyesNone / NoContent / TextOnly / BinaryOnly / All — platform-level attachment filter
isTextAttachmentsAsBinaryservicenotrue delivers text attachments as BinaryAttachment (Blob), not TextAttachment (String)
authorizedSendersservice and addressnoSender allow-list. Blank accepts anyone
authorizationFailureActionserviceyesWhat an unlisted sender gets — even when the allow-list was set on the address
isAuthenticationRequired / authenticationFailureActionserviceno / yesSPF, SenderId and DomainKeys verification of the sending server
isErrorRoutingEnabled / errorRoutingAddressservicenoSend failure notifications to an internal address instead of the sender
functionInactiveActionserviceyesMail arriving while the service is off: UseSystemDefault / Bounce / Discard / Requeue
overLimitActionserviceyesMail arriving after the org's daily limit: same four values
runAsUseraddressyesUsername whose permissions the handler assumes for mail to that address
localPartaddressyesThe 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:

  1. 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.)

  2. 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.

  3. 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:

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:

Email-to-Case vs custom service decision

NeedUse
Create a Case from an emailEmail-to-Case
Auto-response from a templateEmail-to-Case (or On-Demand E2C)
Threading replies to existing CaseEmail-to-Case with [ref:...] token
Create a Lead / Opportunity / Custom ObjectCustom service
Upload a file to a recordCustom service
Trigger a downstream API calloutCustom service
Complex routing (e.g. "if subject starts with X, do Y")Custom service (or E2C with assignment rules — depends)
Multi-language / encoded payloadsCustom 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

SituationApproachReason
Create a Case from emailEmail-to-CaseBuilt-in; threading, routing, auto-response included
Create any other record from emailCustom Email Service + InboundEmailHandlerE2C only creates Cases
Public address receives spamCustom service with allow-listE2C also has spam handling but per-Case
Need to upload filesCustom serviceE2C Email Message attachments tied to Case
Threading replies to existing recordinReplyTo + references first, subject token as fallbackThe 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 serviceThe limit is shared across all email services and On-Demand Email-to-Case; overflow is handled by overLimitAction
Multi-language / RTL / encoded text attachmentCustom handler reading TextAttachment.charsetThe body is re-encoded as UTF-8 for Apex; the original character set survives only on that property
Inbound email triggers a calloutCustom service + Platform Event for async workDon't do the callout in the handler synchronously
Anonymous public access undesirableauthorizedSenders + authorizationFailureActionPlatform-level allow-list; rejects before Apex runs. Add isAuthenticationRequired to verify the sending server via SPF / SenderId / DomainKeys

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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

  1. global is a packaging decision, not an interface requirement. The guide's own samples are public with sharing. (§ 1.)
  2. email.headers is InboundEmail.Header[], not a Map — and the three headers you want are already parsed onto the object. (§ 2.)
  3. success = false mails your message to the sender. Never a stack trace. message is also sent when success is true. (§ 3.)
  4. The ~25 MB ceiling is a platform limit, not a service setting. There is no maxEmailSize element to raise. (§ 4.)
  5. The address domain is generated per address per org and is read-only. Query EmailDomainName; never hardcode. (§ 5.)
  6. runAsUser is Required per address, not per service — two addresses on one service can run the same Apex as different users. (§ 6.)
  7. inReplyTo comes from the sender's client; check references too and keep a subject-token fallback. (§ 7.)
  8. The daily message limit is org-wide and shared with On-Demand Email-to-Case: licences x 1,000, max 1,000,000. (§ 10.)
  9. Email-service Apex gets a 50 MB heap, not 6 MB — keep the Blob work in the handler. (§ 11.)
  10. Sandbox routing addresses cannot be copied to production, and refreshes regenerate them. (§ 12.)
  11. authorizationFailureAction is service-level even when authorizedSenders was set on the address. (§ 13.)
  12. Bodies and text attachments arrive truncated with a flag you must read before parsing. (§ 14.)
  13. EmailServicesFunction accepts no * wildcard in package.xml. Name every service. (§ 16.)

Output Artifacts

ArtifactDescription
Messaging.InboundEmailHandler classThe handler implementation (references/metadata-examples.md)
Test classSynthetic InboundEmail / InboundEnvelope payloads; four named cases
emailservices/<Name>.emailservices-meta.xmlThe service plus its nested address records
package.xml entriesApexClass + EmailServicesFunction (no wildcard)
Running-user permission setOne per address, named in the config workbook
Allow-list sourceCustom Metadata / Custom Setting for admin-managed sender list
Threading strategyinReplyTo / references lookup plus the subject-token format
Routing-address recordLocalPart + EmailDomainName per org, stored in Custom Metadata

Reference Files

FileRead it when
references/metadata-examples.mdYou 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.mdA 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.mdYou 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.mdYou are reviewing AI-generated inbound-email code or advice, or want the detection hints for each recurring mistake
references/well-architected.mdYou 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).


Package files