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, body, headers-as-properties, and attachments, rather than expecting 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 →
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 end result, message text, looks the same in the API response either way.
- 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 →