Skip to main content

Command Palette

Search for a command to run...

How to Decrypt Outlook 365 S/MIME Emails in Java Using Microsoft Graph API

Decrypting encrypted Outlook 365 emails is not as straightforward as calling a single API endpoint. While Microsoft Graph API allows developers to retrieve email content, S/MIME-encrypted messages require additional cryptographic processing befor...

Published
11 min read
How to Decrypt Outlook 365 S/MIME Emails in Java Using Microsoft Graph API
T

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.

1. Why “decrypting Outlook 365 emails” is trickier than it sounds

Most people imagine Microsoft Graph as a magic key that unlocks whatever you can see in Outlook. In reality, Graph is more like a delivery person: it can bring you the message, but if the message is locked with S/MIME encryption, Graph doesn’t automatically open it for you. That’s not a limitation by accident—it’s the whole point of end-to-end encryption: only the recipient with the right private key can decrypt the content. In practice, Graph can hand you the raw message (including the encrypted blob), and then your Java app does the actual cryptography locally. Microsoft’s own community answers and Q&A threads repeatedly confirm Graph doesn’t provide a “decrypt this S/MIME email” endpoint; you retrieve encrypted content and decrypt it yourself. (Stack Overflow)

1.1. Pick the encryption type first: S/MIME vs OME (because they behave very differently)

When people say “encrypted email in Microsoft 365,” they might mean S/MIME or Office Message Encryption (OME). OME is a Microsoft 365 feature where Microsoft manages keys and provides a viewing experience (often via a portal for external recipients). S/MIME is certificate-based end-to-end encryption where the recipient’s private key is essential. OME is designed for broad compatibility and managed workflows; S/MIME is designed for cryptographic control. So, if your mailbox receives an OME-protected message, your path is usually “use supported Microsoft viewing/workflow mechanisms,” while S/MIME is the one you can realistically decrypt in your own Java code—if your app has access to the correct private key. (Microsoft Learn)

1.2. What Microsoft Graph actually gives you: the MIME message, not the plaintext

Graph supports retrieving the MIME representation of a message by calling the message endpoint with the /$value segment. This is the critical detail: MIME is where S/MIME encryption and signatures actually live (for example as application/pkcs7-mime). If you retrieve the “nice JSON body,” you might not get what you need for cryptography; if you retrieve MIME, you get the real raw payload that contains the encrypted CMS/PKCS#7 structure. Microsoft documents this $value approach explicitly. (Microsoft Learn)

1.3. The security reality check your future self will thank you for

If your app can decrypt mailbox emails, it’s effectively holding the same power as the mailbox owner. That means the real work is not “calling Graph,” it’s making sure you do key handling responsibly: secure storage for private keys, strict access control, and audit logging. Otherwise, you’re not building “email decryption,” you’re building a very expensive data leak generator with great uptime.

2. Architecture that works in the real world

A practical approach is: authenticate to Microsoft Graph, download the message as MIME, detect whether it’s S/MIME encrypted, decrypt the CMS/PKCS#7 envelope using the recipient private key, then parse the decrypted inner MIME to extract the readable body and attachments. Graph is the transport; Java does the cryptography and parsing.

2.1. Authentication and permissions: getting to the mailbox without getting blocked

You typically register an app in Microsoft Entra ID (Azure AD), then use OAuth2 (Authorization Code for user sign-in, or Client Credentials for daemon access where permitted). For mailbox reads, permissions like Mail.Read or Mail.ReadBasic are common, but the exact choice depends on whether you’re acting as a user or an application. The important part is that once you have an access token, the MIME download is just an HTTP GET to the /$value endpoint. (Microsoft Learn)

2.2. Getting the MIME content with Graph: the one call you must get right

The Graph call pattern is straightforward: GET /users/{id}/messages/{id}/$value. That returns the raw RFC 5322 message bytes. This is exactly what you want for S/MIME because it preserves the encrypted part in its original form. (Microsoft Learn)

2.3. Decrypting S/MIME in Java: what “decrypt” actually means

An S/MIME encrypted email is usually a CMS “EnvelopedData” object wrapped as a MIME part (commonly application/pkcs7-mime). Decrypting it means: locate the CMS blob, load the recipient certificate + private key, and ask a CMS library to decrypt the content encryption key and then the content. In Java, this is most commonly done with Bouncy Castle. Once decrypted, you typically get another MIME message (the real body), which you parse again to read the subject/body/attachments.

3. Full Java example: download MIME via Graph, decrypt S/MIME with Bouncy Castle, parse plaintext

The example below focuses on the core pipeline and keeps the moving parts visible. It uses plain HTTP for Graph (so you’re not locked to a specific SDK style), Bouncy Castle for CMS decryption, and Jakarta Mail for MIME parsing. The code assumes you already have an access token and that you’re allowed to read the message.

import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.BodyPart;

import org.bouncycastle.cms.;
import org.bouncycastle.cms.jcajce.
;
import org.bouncycastle.jce.provider.BouncyCastleProvider;

import java.io.;
import java.net.URI;
import java.net.http.
;
import java.nio.charset.StandardCharsets;
import java.security.;
import java.security.cert.X509Certificate;
import java.util.Properties;

public class GraphSmimeDecryptor {

static {
Security.addProvider(new BouncyCastleProvider());
}

public static void main(String[] args) throws Exception {
String accessToken = System.getenv("GRAPH_ACCESS_TOKEN");
String userId = "me"; // or a user GUID/email for /users/{id}
String messageId = System.getenv("GRAPH_MESSAGE_ID");

// 1) Download raw MIME from Microsoft Graph
byte[] rawMime = downloadMessageMime(accessToken, userId, messageId);

// 2) Parse the MIME so we can locate the S/MIME encrypted part
MimeMessage outer = parseMime(rawMime);

// 3) Extract CMS/PKCS7 bytes from the message (simple/common S/MIME layout)
byte[] pkcs7 = extractPkcs7Payload(outer);
if (pkcs7 == null) {
throw new IllegalStateException("No S/MIME PKCS#7 payload found. Message may be unencrypted or uses a different layout.");
}

// 4) Load recipient private key + certificate used for S/MIME decryption
KeyMaterial km = loadPkcs12("recipient-keystore.p12", "p12Password".toCharArray());

// 5) Decrypt CMS envelope => yields inner (plaintext) MIME bytes
byte[] innerMimeBytes = decryptCmsEnvelopedData(pkcs7, km.certificate, km.privateKey);

// 6) Parse decrypted MIME and print a readable body
MimeMessage inner = parseMime(innerMimeBytes);
String bodyText = extractBestEffortBodyText(inner);

System.out.println("=== Decrypted Subject ===");
System.out.println(inner.getSubject());
System.out.println(" === Decrypted Body (best effort) ===");
System.out.println(bodyText);
}

// ---------------- Graph download ----------------

static byte[] downloadMessageMime(String accessToken, String userId, String messageId) throws Exception {
String url = "https://graph.microsoft.com/v1.0/" +
(userId.equals("me") ? "me" : "users/" + userId) +
"/messages/" + messageId + "/$value";

HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + accessToken)
.header("Accept", "application/octet-stream")
.GET()
.build();

HttpResponse<byte[]> resp = client.send(req, HttpResponse.BodyHandlers.ofByteArray());
if (resp.statusCode() != 200) {
String msg = new String(resp.body(), StandardCharsets.UTF_8);
throw new IOException("Graph returned " + resp.statusCode() + ": " + msg);
}
return resp.body();
}

// ---------------- MIME parsing ----------------

static MimeMessage parseMime(byte[] raw) throws Exception {
Session session = Session.getInstance(new Properties());
try (InputStream is = new ByteArrayInputStream(raw)) {
return new MimeMessage(session, is);
}
}

/**
Common S/MIME encrypted messages look like:
Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m
This method tries a few typical places to find that blob.
/
static byte[] extractPkcs7Payload(MimeMessage msg) throws Exception {
Object content = msg.getContent();

// Case A: the whole message content is already the pkcs7 part
String ct = msg.getContentType().toLowerCase();
if (ct.contains("application/pkcs7-mime") || ct.contains("application/x-pkcs7-mime")) {
return readAllBytes(msg.getInputStream());
}

// Case B: multipart wrapper where one part is pkcs7
if (content instanceof MimeMultipart mm) {
for (int i = 0; i < mm.getCount(); i++) {
BodyPart bp = mm.getBodyPart(i);
String pct = bp.getContentType().toLowerCase();
if (pct.contains("application/pkcs7-mime") || pct.contains("application/x-pkcs7-mime")) {
try (InputStream is = bp.getInputStream()) {
return readAllBytes(is);
}
}
}
}

return null;
}

static byte[] readAllBytes(InputStream is) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int r;
while ((r = is.read(buf)) != -1) {
bos.write(buf, 0, r);
}
return bos.toByteArray();
}

// ---------------- Key loading ----------------

static class KeyMaterial {
final X509Certificate certificate;
final PrivateKey privateKey;
KeyMaterial(X509Certificate cert, PrivateKey pk) { this.certificate = cert; this.privateKey = pk; }
}

static KeyMaterial loadPkcs12(String p12Path, char[] password) throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
try (InputStream is = new FileInputStream(p12Path)) {
ks.load(is, password);
}

String alias = ks.aliases().nextElement();
PrivateKey privateKey = (PrivateKey) ks.getKey(alias, password);
X509Certificate cert = (X509Certificate) ks.getCertificate(alias);

if (privateKey == null || cert == null) {
throw new IllegalStateException("Could not load key material from PKCS#12 keystore.");
}
return new KeyMaterial(cert, privateKey);
}

// ---------------- CMS decrypt ----------------

static byte[] decryptCmsEnvelopedData(byte[] pkcs7, X509Certificate recipientCert, PrivateKey recipientKey) throws Exception {
CMSEnvelopedData enveloped = new CMSEnvelopedData(pkcs7);

RecipientInformationStore recipients = enveloped.getRecipientInfos();
RecipientId rid = new JceKeyTransRecipientId(recipientCert);

RecipientInformation recipient = recipients.get(rid);
if (recipient == null) {
// If cert matching fails, fall back to first recipient as a last resort
recipient = recipients.getRecipients().stream().findFirst().orElse(null);
}
if (recipient == null) {
throw new IllegalStateException("No recipients found in CMS envelope.");
}

JceKeyTransEnvelopedRecipient r = new JceKeyTransEnvelopedRecipient(recipientKey).setProvider("BC");
return recipient.getContent(r);
}

// ---------------- Body extraction ----------------

static String extractBestEffortBodyText(MimeMessage msg) throws Exception {
Object content = msg.getContent();
if (content instanceof String s) return s;

if (content instanceof MimeMultipart mm) {
// Prefer text/plain if present; otherwise fall back to first text/

String fallback = null;
for (int i = 0; i < mm.getCount(); i++) {
BodyPart bp = mm.getBodyPart(i);
String ct = bp.getContentType().toLowerCase();
if (ct.startsWith("text/plain")) return (String) bp.getContent();
if (fallback == null && ct.startsWith("text/")) fallback = (String) bp.getContent();
}
if (fallback != null) return fallback;
}
return "[No readable text body found]";
}
}

3.1. What the Graph download code is doing, line by line in plain English

The downloadMessageMime method calls the Graph endpoint that returns message bytes, not a JSON object. That’s intentional, because S/MIME is a MIME-level encryption mechanism, so you want the raw RFC 5322 content that still contains the PKCS#7 envelope. Microsoft documents this MIME retrieval pattern via the /$value segment, so you’re using the API the way it was designed, not fighting it. (Microsoft Learn)

3.2. Why the MIME parsing step matters before you decrypt

S/MIME encrypted email isn’t “some encrypted text field.” It’s typically a MIME part whose content-type indicates PKCS#7. The extractPkcs7Payload method is doing the boring but necessary work of locating that part, because real emails vary: sometimes the whole message is the pkcs7 blob, sometimes it’s a multipart wrapper, sometimes clients add extra structure. If you skip this step and just “decrypt the whole message,” you’ll often decrypt the wrong bytes and get garbage.

3.3. How the decryption works without any hand-wavy “crypto magic”

decryptCmsEnvelopedData treats the PKCS#7 bytes as CMS EnvelopedData. Inside that envelope is a list of recipients; each recipient entry contains an encrypted content key intended for a specific certificate. The code tries to match the recipient using your certificate, and if that fails (because life is messy and email clients are creative), it falls back to the first recipient as a last resort. Then Bouncy Castle uses your private key to unwrap the content key and decrypts the payload, giving you the inner plaintext MIME bytes. That inner payload is usually another full email message, which is why you parse MIME again and then extract the human-readable body.

4. Practical problems you’ll hit in production and how to think about them

S/MIME projects rarely fail because “Graph didn’t work.” They fail because keys, formats, and policies get involved like a committee that all wants to be the decision-maker.

4.1. Key management: where do you store the private key without creating a disaster

If you keep a .p12 next to your JAR, congratulations: you’ve invented “encryption theatre.” In production, you typically store private keys in an HSM, a cloud key vault, or at minimum an encrypted secret store with strict access control. The point is to make it harder for an attacker (or an overly-curious teammate) to turn your service into a mailbox reader with benefits.

4.2. Attachments and rich content: the decrypted email can still be complex

Once you decrypt the inner MIME, you may see nested multiparts, inline images, and attachments. Treat “extract body text” as a convenience, not the whole story. In real apps, you usually walk the MIME tree and persist attachments separately, while also sanitizing HTML if you display it anywhere (because email HTML is the original “wild west”).

4.3. What about sending encrypted emails from Java with Graph

Graph supports sending messages in MIME format, and Microsoft’s docs call out that S/MIME signatures and encryption are applied at the MIME level. So the pattern for sending is: you create a MIME message that is already signed/encrypted, base64 it, and send it via sendMail. (Microsoft Learn)

5. Closing thoughts

Decrypting Outlook 365 S/MIME emails with Java and Microsoft Graph is less about finding a hidden Graph endpoint and more about building a clean, defensible pipeline: fetch raw MIME, detect the PKCS#7 envelope, decrypt with the right private key, then parse the decrypted MIME like a normal message. Once you get that working, the “grown-up” work starts: key security, attachment handling, and making sure your service doesn’t become the world’s most efficient privacy violation.

5.1. Your turn

What kind of encrypted emails are you dealing with in your tenant right now—pure S/MIME, OME, or a mix—and do you need only the body text, or also attachments and inline images? Drop your question in the comments below.

Read more at : How to Decrypt Outlook 365 S/MIME Emails in Java Using Microsoft Graph API

More from this blog

T

tuanh.net

540 posts

Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!