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
curl -X POST https://api.txtfetch.com/v1/extract \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@forwarded-thread.msg
Python
import 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"])
JavaScript
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);
Go
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
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/exports/mailbox-2024.pst" \
  -H "Authorization: Bearer $TXTFETCH_KEY"
Python
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"])
JavaScript
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);
Go
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)

sprint-status-update.msg · application/vnd.ms-outlook · left pane read via Apache Tika, right pane: the human-checked expected.json

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, Dana

See 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 →

Open the free .msg reader →