https://txtfetch.com/extract/msg/
Outlook .msg isn't email, structurally.
MAPI property streams inside an OLE2 container. Nested .msg-in-.msg attachments. Whole mailbox databases. None of it looks like an .eml file.
the-problem
It's easy to assume .msg is just email in a different wrapper. Structurally, though, it has nothing to do with .eml. A .msg file is an OLE2 compound-file container, the same binary-filesystem-in-a-file format as legacy .doc/.xls. It holds MAPI property streams named things like __substg1.0_37010102 for the body, or __substg1.0_1000001E for plain text. There's no MIME and no RFC-822 headers to parse. Every .eml/RFC-822 parser fails on a .msg outright, because it isn't looking at the right binary shape at all. Attachments are embedded OLE storages inside the same container. A forwarded or embedded email shows up as a .msg nested inside a .msg. Zoom out further, and .pst/.ost files aren't single messages at all. They're whole mailbox databases, potentially thousands of messages in one binary file.
one-request-solution
txtfetch detects the OLE2 signature and routes .msg through Tika's MAPI-aware parser. That parser reads the property streams directly: the body, the subject, the sender name, To, Cc and the attachments. It does not expect MIME. Nested .msg-in-.msg attachments are walked recursively. And .pst/.ost mailbox files are unpacked message by message. All of it goes through the same /v1/extract endpoint and response shape as a plain .eml.
curl -X POST https://api.txtfetch.com/v1/extract \
-H "Authorization: Bearer $TXTFETCH_KEY" \
-F file=@forwarded-thread.msgimport os
import requests
with open("forwarded-thread.msg", "rb") as f:
r = requests.post(
"https://api.txtfetch.com/v1/extract",
headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
files={"file": f},
)
print(r.json()["extracted_text"])import { readFile } from "node:fs/promises";
const file = new Blob([await readFile("forwarded-thread.msg")]);
const form = new FormData();
form.append("file", file, "forwarded-thread.msg");
const res = await fetch("https://api.txtfetch.com/v1/extract", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
body: form,
});
const { extracted_text } = await res.json();
console.log(extracted_text);package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
type extractResponse struct {
Status string `json:"status"`
ExtractedText string `json:"extracted_text"`
}
func main() {
f, err := os.Open("forwarded-thread.msg")
if err != nil {
panic(err)
}
defer f.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "forwarded-thread.msg")
if err != nil {
panic(err)
}
if _, err := io.Copy(part, f); err != nil {
panic(err)
}
writer.Close()
req, err := http.NewRequest("POST", "https://api.txtfetch.com/v1/extract", &body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result extractResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.ExtractedText)
}Or skip the download. Pass a url parameter and txtfetch fetches the document server-side:
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/exports/mailbox-2024.pst" \
-H "Authorization: Bearer $TXTFETCH_KEY"import os
import requests
r = requests.post(
"https://api.txtfetch.com/v1/extract",
headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
params={"url": "https://example.com/exports/mailbox-2024.pst"},
)
print(r.json()["extracted_text"])const endpoint = new URL("https://api.txtfetch.com/v1/extract");
endpoint.searchParams.set("url", "https://example.com/exports/mailbox-2024.pst");
const res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
});
const { extracted_text } = await res.json();
console.log(extracted_text);package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
type extractResponse struct {
Status string `json:"status"`
ExtractedText string `json:"extracted_text"`
}
func main() {
endpoint, err := url.Parse("https://api.txtfetch.com/v1/extract")
if err != nil {
panic(err)
}
q := endpoint.Query()
q.Set("url", "https://example.com/exports/mailbox-2024.pst")
endpoint.RawQuery = q.Encode()
req, err := http.NewRequest("POST", endpoint.String(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result extractResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.ExtractedText)
}{
"status": "success",
"extracted_text": "..."
}what-comes-back
That is the shape. This is the text. A real .msg from the benchmark corpus, word-diffed against the raw parser output. Nothing here was written for this page. Every character comes from a committed fixture.
Sprint Status Update (Outlook MSG)
raw parser output
From: Dana Lee <dana@example.com>
To: Engineering <eng@example.com>
Subject: Sprint Status Update
Hi all,
Here is where things stand heading into the demo on Friday.
Benchmark harness: on track
Corpus curation: on track
CI wiring: at risk, needs a second reviewer
Ping me if you want to pair on the CI job.
Thanks,
Dana
the human-checked ideal
From: Dana Lee <dana@example.com>
To: Engineering <eng@example.com>
Subject: Sprint Status Update
Hi all,
Here is where things stand heading into the demo on Friday.
Benchmark harness: on track
Corpus curation: on track
CI wiring: at risk, needs a second reviewer
Ping me if you want to pair on the CI job.
Thanks, DanaSee all ten documents, with the full explanation →
try-your-own
Drop your own .msg file below. Your browser reads it. Nothing uploads, and txtfetch never sees it.
Stays in your browser. Up to 25 MB.
What's not in this text:
Your browser read this file. No request left your browser, so txtfetch never received it. The API returns this same text as { status, extracted_text }.
See the real API response in the playground. Need many files, a folder, or a .zip? Use file-to-text instead.
formats-covered
.msg.eml.mbox.pst.ost
faq
- Why does my .eml parser fail on a .msg file?
- Because .msg isn't RFC-822/MIME at all. It's an OLE2 compound-file container with MAPI property streams. Any parser built around parsing email headers and MIME parts is looking for a structure that simply isn't there in a .msg file.
- How is .msg different from the .eml page on this site?
- /extract/email covers RFC-822 .eml and .mbox: text-based, MIME-encoded formats. This page covers .msg, .pst, and .ost instead: OLE2-based Outlook formats with a completely different binary structure. The API response has the same shape either way. The content differs: a .msg returns its subject, sender name, To and Cc, and an .eml returns no headers.
- Can a .msg file contain another .msg file?
- Yes. A forwarded or embedded message shows up as a nested OLE2 storage inside the parent .msg. txtfetch walks these recursively rather than stopping at the first level.
- Can I extract a whole .pst or .ost mailbox in one request?
- Yes. .pst and .ost are whole mailbox databases rather than single messages. txtfetch unpacks and extracts the contained messages' text through the same endpoint.
go-further
Send a real .msg through it.
One HTTP call returns the text. Read one in your browser first, for free.
Get an API key →