Short answer: an email service is an Apex class implementing Messaging.InboundEmailHandler, bound to one or more generated email addresses, that receives a parsed message and returns a result object. Setting result.success = false tells the platform the message was not handled, and what happens then depends on the failure response setting on the service. The two things that catch people out are attachments, which are off until you enable them, and the daily processing cap, which is a function of your user licence count.
The handler
global class CaseIntakeHandler implements Messaging.InboundEmailHandler {
global Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email,
Messaging.InboundEnvelope env
) {
Messaging.InboundEmailResult result = new Messaging.InboundEmailResult();
try {
Case c = new Case(
Subject = email.subject,
Description = String.isNotBlank(email.plainTextBody)
? email.plainTextBody
: email.htmlBody,
SuppliedEmail = env.fromAddress,
Origin = 'Email'
);
insert c;
attachFiles(c.Id, email);
result.success = true;
} catch (Exception e) {
result.success = false;
result.message = 'Could not create case: ' + e.getMessage();
}
return result;
}
}
The class must be global and the method must be global, because the platform calls it from outside your namespace. The envelope carries the SMTP level addresses, which can differ from the header addresses in email.fromAddress. For anything that matters, trust the envelope.
What is on the InboundEmail object
subject,plainTextBody,htmlBodyand the matchingplainTextBodyIsTruncatedflags.fromAddress,fromName,toAddresses,ccAddresses,replyTo.headers, a list of name and value pairs, which is where you find custom tracking headers.messageId,inReplyToandreferences, which together let you thread a reply onto the right record.binaryAttachmentsandtextAttachments.
Threading deserves a mention. If you put a token in the subject, users will edit it. inReplyTo and references come from the mail client and are far more reliable for matching a reply to the original outbound message, provided you recorded the message id when you sent it.
Attachments
Attachments arrive in two collections. Messaging.InboundEmail.BinaryAttachment has body as a Blob, plus fileName and mimeTypeSubType. Messaging.InboundEmail.TextAttachment has body as a String.
private void attachFiles(Id parentId, Messaging.InboundEmail email) {
if (email.binaryAttachments == null) { return; }
List<ContentVersion> versions = new List<ContentVersion>();
for (Messaging.InboundEmail.BinaryAttachment a : email.binaryAttachments) {
versions.add(new ContentVersion(
Title = a.fileName,
PathOnClient = a.fileName,
VersionData = a.body,
FirstPublishLocationId = parentId
));
}
if (!versions.isEmpty()) { insert versions; }
}
Both collections are null rather than empty when there is nothing, so guard before iterating. And attachments are not delivered at all unless the email service has attachment handling enabled in setup, which is a separate setting from the service being active. Code that works in one org and drops files in another is almost always this.
Watch the heap. Attachment bodies land in the 6 MB synchronous heap along with everything else, so a handler that loops several files into memory at once can fail on size alone.
The failure response
Returning success = false, or throwing an uncaught exception, marks the message as unhandled. The email service configuration then decides what the sender sees: a bounce, silence, or a requeue for some conditions. The result.message text is what goes back to the sender when a response is sent, so write it for a human and never put an exception stack trace in it.
There is a separate set of settings for messages from unauthorised senders, oversized messages and inactive addresses. Each can bounce, discard or requeue. Discarding silently is the usual production choice for unauthorised senders, because bouncing tells a spammer the address is live.
Addresses and limits
One service can have many addresses, each with its own accepted sender list and its own active flag. That is how you route regional intake to the same class with a context value, and how you retire an address without touching Apex.
The daily processing limit is the number of user licences multiplied by 1,000, capped at 1,000,000 messages a day across the org. Messages beyond the cap are rejected according to the over limit setting. For high volume intake, this is the number to check before promising anything.
The handler runs under normal Apex governor limits per message, in the context user configured on the service. That user’s permissions and sharing apply, so a handler that works for you as an admin can fail for a restricted context user.
Frequently asked
Can I make callouts from the handler? Not directly in a way you should rely on. Enqueue a Queueable with the parsed data and do the callout there, which also keeps the handler fast.
How do I test it? Construct a Messaging.InboundEmail and a Messaging.InboundEnvelope in the test, populate the fields, and call handleInboundEmail directly. Both classes are instantiable in Apex, so no mocking framework is needed.
Why is plainTextBody empty? Because the sender sent HTML only. Always fall back to htmlBody, and strip the markup before writing it to a text field.
Does Email-to-Case use this? On Demand Email-to-Case is a separate managed feature with its own routing. An email service is what you build when you need behaviour Email-to-Case does not offer.
