Skip to content
Back to skills

Email Ingestion

ASecurity

Email ingestion for RAG. Covers EML (Python email module, mailparser), MSG (extract-msg), Gmail and Outlook APIs, thread reconstruction via In-Reply-To / References, recursive attachment handling, signature/quote redaction, chain deduplication, and metadata (sender, date, subject) preservation. USE WHEN: user mentions "email ingestion", "EML", "MSG file", "mailparser", "extract-msg", "Gmail API", "Outlook API", "Microsoft Graph mail", "email thread", "reply-to header", "email attachments", "...

  • 31 stars
  • 0 votes
  • 0 copies
  • 6 views
  • Added September 8, 2026
ai-agentspythongoazureapi

Works with

  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 8, 2026

npx -y skills add claude-dev-suite/claude-dev-suite --skill email-ingestion --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Email Ingestion?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Email Ingestion
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/claude-dev-suite-email-ingestion/badge)](https://www.skillsdirectory.com/skills/claude-dev-suite-email-ingestion)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: email-ingestion
description: |
  Email ingestion for RAG. Covers EML (Python email module, mailparser), MSG
  (extract-msg), Gmail and Outlook APIs, thread reconstruction via In-Reply-To /
  References, recursive attachment handling, signature/quote redaction, chain
  deduplication, and metadata (sender, date, subject) preservation.

  USE WHEN: user mentions "email ingestion", "EML", "MSG file", "mailparser",
  "extract-msg", "Gmail API", "Outlook API", "Microsoft Graph mail",
  "email thread", "reply-to header", "email attachments", "signature stripping"

  DO NOT USE FOR: PDF attachments inside emails (extract here, then route to `pdf-extraction`);
  office attachments (route to `office-docs`);
  image attachments needing OCR - route to `ocr`;
  calendar ICS files - not covered here
allowed-tools: Read, Grep, Glob, Write, Edit
---
# Email Ingestion for RAG

## Format Matrix

| Source | Library | Attachments | Threading Info | Auth |
|--------|---------|-------------|----------------|------|
| EML files | `email` (stdlib), `mailparser` | Yes | Headers | None |
| MSG files (Outlook) | `extract-msg` | Yes | Headers + MAPI props | None |
| Gmail API | `google-api-python-client` | Download via attachmentId | `threadId` | OAuth |
| Outlook / MS365 | `msgraph-sdk` | `/attachments` endpoint | `conversationId` | OAuth |
| mbox | `mailbox` (stdlib) | Yes | Headers | None |

## EML — Python stdlib

```python
from email import policy
from email.parser import BytesParser
from email.utils import parseaddr, parsedate_to_datetime

def parse_eml(path: str) -> dict:
    with open(path, "rb") as f:
        msg = BytesParser(policy=policy.default).parse(f)

    body_text = ""
    body_html = ""
    attachments: list[dict] = []
    for part in msg.walk():
        if part.is_multipart():
            continue
        disp = (part.get("Content-Disposition") or "").lower()
        ctype = part.get_content_type()
        if "attachment" in disp or part.get_filename():
            attachments.append({
                "filename": part.get_filename(),
                "content_type": ctype,
                "payload": part.get_payload(decode=True),
                "content_id": part.get("Content-ID"),
            })
        elif ctype == "text/plain" and not body_text:
            body_text = part.get_content()
        elif ctype == "text/html" and not body_html:
            body_html = part.get_content()

    return {
        "message_id": msg.get("Message-ID"),
        "in_reply_to": msg.get("In-Reply-To"),
        "references": (msg.get("References") or "").split(),
        "from": parseaddr(msg.get("From") or ""),
        "to": [parseaddr(a) for a in (msg.get_all("To") or [])],
        "cc": [parseaddr(a) for a in (msg.get_all("Cc") or [])],
        "subject": msg.get("Subject"),
        "date": parsedate_to_datetime(msg.get("Date")) if msg.get("Date") else None,
        "body_text": body_text,
        "body_html": body_html,
        "attachments": attachments,
    }
```

## mailparser (Higher Level)

```python
import mailparser

m = mailparser.parse_from_file("note.eml")
print(m.from_, m.to, m.subject, m.date)
print(m.body)
for att in m.attachments:
    with open(f"out/{att['filename']}", "wb") as f:
        f.write(att["payload"].encode() if isinstance(att["payload"], str)
                else att["payload"])
```

## MSG — Outlook Files

```python
import extract_msg

msg = extract_msg.Message("meeting.msg")
data = {
    "sender": msg.sender,
    "to": msg.to,
    "cc": msg.cc,
    "subject": msg.subject,
    "date": msg.date,
    "body": msg.body,                 # plain text
    "html": msg.htmlBody,
    "headers": dict(msg.header.items()) if msg.header else {},
}

for att in msg.attachments:
    with open(f"out/{att.longFilename}", "wb") as f:
        f.write(att.data)

# Embedded MSGs (forwarded messages) are recursive
for att in msg.attachments:
    if isinstance(att.data, extract_msg.Message):
        inner = att.data
        print("embedded subject:", inner.subject)
```

## Gmail API

```python
import os, base64
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials

creds = Credentials.from_authorized_user_file("token.json",
                                               ["https://www.googleapis.com/auth/gmail.readonly"])
svc = build("gmail", "v1", credentials=creds)

def list_messages(query: str = "newer_than:30d"):
    resp = svc.users().messages().list(userId="me", q=query, maxResults=500).execute()
    return resp.get("messages", [])

def fetch_message(msg_id: str) -> dict:
    m = svc.users().messages().get(userId="me", id=msg_id, format="full").execute()
    headers = {h["name"].lower(): h["value"] for h in m["payload"]["headers"]}

    def walk(part):
        if "parts" in part:
            for p in part["parts"]:
                yield from walk(p)
        else:
            yield part

    text_parts, attachments = [], []
    for part in walk(m["payload"]):
        body = part.get("body", {})
        if body.get("attachmentId"):
            att = svc.users().messages().attachments().get(
                userId="me", messageId=msg_id, id=body["attachmentId"]
            ).execute()
            data = base64.urlsafe_b64decode(att["data"])
            attachments.append({
                "filename": next((h["value"] for h in part.get("headers", [])
                                  if h["name"].lower() == "content-disposition"), ""),
                "mime": part.get("mimeType"),
                "data": data,
            })
        elif part.get("mimeType") == "text/plain" and body.get("data"):
            text_parts.append(base64.urlsafe_b64decode(body["data"]).decode("utf-8", "replace"))

    return {
        "id": msg_id,
        "thread_id": m.get("threadId"),
        "from": headers.get("from"),
        "subject": headers.get("subject"),
        "date": headers.get("date"),
        "message_id": headers.get("message-id"),
        "in_reply_to": headers.get("in-reply-to"),
        "references": (headers.get("references") or "").split(),
        "body": "\n".join(text_parts),
        "attachments": attachments,
    }
```

## Outlook via Microsoft Graph

```python
from msgraph import GraphServiceClient
from azure.identity.aio import ClientSecretCredential

cred = ClientSecretCredential(
    tenant_id=os.environ["MS_TENANT_ID"],
    client_id=os.environ["MS_CLIENT_ID"],
    client_secret=os.environ["MS_CLIENT_SECRET"],
)
graph = GraphServiceClient(credentials=cred, scopes=["https://graph.microsoft.com/.default"])

async def list_messages(user_id: str):
    page = await graph.users.by_user_id(user_id).messages.get()
    return page.value

async def get_attachments(user_id: str, message_id: str):
    return (await graph.users.by_user_id(user_id)
            .messages.by_message_id(message_id)
            .attachments.get()).value
```

## Thread Reconstruction

```python
from collections import defaultdict

def build_threads(messages: list[dict]) -> dict[str, list[dict]]:
    by_id = {m["message_id"]: m for m in messages if m.get("message_id")}
    children = defaultdict(list)
    roots = []
    for m in messages:
        parent = m.get("in_reply_to")
        if parent and parent in by_id:
            children[parent].append(m)
        else:
            roots.append(m)

    threads: dict[str, list[dict]] = {}
    def walk(root):
        out = [root]
        for c in sorted(children[root["message_id"]], key=lambda x: x.get("date") or ""):
            out.extend(walk(c))
        return out

    for root in roots:
        threads[root["message_id"]] = walk(root)
    return threads
```

For Gmail / Outlook the provider already exposes `threadId` / `conversationId`. Prefer those when available; fall back to header-based reconstruction for EML/mbox corpora.

## Signature and Reply-Quote Stripping

```python
import re
from email_reply_parser import EmailReplyParser  # pip install email-reply-parser

SIGNATURE_MARKERS = [
    r"^-- $",                        # RFC 3676 sig separator
    r"^Sent from my (iPhone|iPad|Android)",
    r"^Get Outlook for",
    r"^Best regards,",
    r"^Thanks,?\s*$",
    r"^Cheers,?\s*$",
]
SIG_RE = re.compile("|".join(SIGNATURE_MARKERS), re.MULTILINE | re.IGNORECASE)

def clean_body(text: str) -> str:
    # Strip quoted replies ("On <date> X wrote:" blocks and ">" lines)
    reply = EmailReplyParser.parse_reply(text)
    # Cut at first signature marker
    m = SIG_RE.search(reply)
    if m:
        reply = reply[: m.start()]
    # Drop quoted lines as last resort
    lines = [ln for ln in reply.splitlines() if not ln.lstrip().startswith(">")]
    return "\n".join(lines).strip()
```

## Attachment Recursion

```python
from pathlib import Path

def route_attachment(att: dict, base_dir: Path) -> list[dict]:
    name = att["filename"] or "unnamed"
    ext = name.lower().rsplit(".", 1)[-1]
    blob_path = base_dir / name
    blob_path.write_bytes(att["payload"] or att.get("data", b""))

    # Dispatch to the right skill
    if ext == "pdf":
        from mymodule.pdf import extract_for_rag
        return extract_for_rag(str(blob_path))          # pdf-extraction skill
    if ext in {"docx", "xlsx", "pptx"}:
        from mymodule.office import extract as office_extract
        return office_extract(str(blob_path))           # office-docs skill
    if ext in {"png", "jpg", "jpeg", "tiff"}:
        from mymodule.ocr import run_ocr
        return run_ocr(str(blob_path))                  # ocr skill
    if ext in {"eml", "msg"}:
        return ingest_email_file(str(blob_path))        # recursive
    return []
```

## Deduplication

```python
import hashlib

def email_key(msg: dict) -> str:
    # Prefer Message-ID; fall back to hash of normalized headers+body
    if msg.get("message_id"):
        return msg["message_id"].strip("<>").lower()
    body = clean_body(msg.get("body_text") or msg.get("body") or "")
    basis = f"{msg.get('from')}|{msg.get('subject')}|{msg.get('date')}|{body[:500]}"
    return hashlib.sha256(basis.encode()).hexdigest()

def dedupe(messages: list[dict]) -> list[dict]:
    seen: set[str] = set()
    out = []
    for m in messages:
        k = email_key(m)
        if k in seen:
            continue
        seen.add(k)
        out.append(m)
    return out
```

## Privacy Filtering

```python
PII_PATTERNS = {
    "email": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Za-z]{2,}",
    "phone": r"\+?\d[\d \-().]{7,}\d",
    "ssn":   r"\b\d{3}-\d{2}-\d{4}\b",
    "cc":    r"\b(?:\d[ -]?){13,16}\b",
}

def redact(text: str, types: list[str] = ("ssn", "cc")) -> str:
    for t in types:
        text = re.sub(PII_PATTERNS[t], f"[REDACTED_{t.upper()}]", text)
    return text
```

## RAG Chunking

```python
from dataclasses import dataclass
from datetime import datetime

@dataclass
class EmailChunk:
    text: str
    message_id: str
    thread_id: str
    sender: str
    subject: str
    date: str
    position_in_thread: int

def email_to_chunks(msg: dict, thread_id: str, position: int) -> list[EmailChunk]:
    body = clean_body(msg.get("body_text") or msg.get("body") or "")
    if not body:
        return []
    return [EmailChunk(
        text=f"Subject: {msg.get('subject')}\nFrom: {msg.get('from')}\n\n{body}",
        message_id=msg.get("message_id") or "",
        thread_id=thread_id,
        sender=str(msg.get("from")),
        subject=msg.get("subject") or "",
        date=msg.get("date").isoformat() if isinstance(msg.get("date"), datetime) else str(msg.get("date")),
        position_in_thread=position,
    )]
```

## Anti-Patterns

| Anti-Pattern | Fix |
|---|---|
| Indexing every reply in a chain separately | Keep threads grouped; index latest + unique content |
| Keeping signatures in embeddings | Strip with `EmailReplyParser` + signature regex |
| Ignoring `In-Reply-To` / `References` | Use them to reconstruct threads for EML/mbox |
| Skipping attachments | Recurse: PDFs -> pdf-extraction, docs -> office-docs |
| Storing full HTML bodies | Convert to text or markdown first |
| Using subject prefix matching for threads | Unreliable; prefer `threadId`/`Message-ID` |
| Leaking PII into embeddings | Redact before embedding when required by policy |
| Re-downloading Gmail messages | Persist `historyId`; use `users.history.list` for deltas |

## Production Checklist

- [ ] Canonical `message_id` stored on every chunk
- [ ] Thread reconstruction via provider ID first, headers second
- [ ] Reply-quote + signature stripped before embedding
- [ ] Attachments routed to the right specialist skill and cross-linked by `message_id`
- [ ] PII redaction configurable per tenant / per corpus
- [ ] Incremental sync (`historyId` for Gmail, `delta` query for Graph)
- [ ] Dedup keyed on Message-ID with hash fallback
- [ ] Metadata preserved: from, to, cc, subject, date, thread_id, position_in_thread
- [ ] HTML bodies converted to markdown/text; inline images handled via Content-ID

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.