Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1cc3236
feat(formats): add plain text and Markdown input
chaliy Oct 26, 2026
7e3a233
fix(text): use as_chunks for UTF-16 decoding (clippy)
chaliy Oct 26, 2026
ba30062
chore(knowledge): scope S13 to markup copying, note text escaping res…
chaliy Oct 26, 2026
510056e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
3b0b504
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
3526d61
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
62afeb8
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
573003f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
511f20a
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
0ee47be
feat(formats): add CSV and TSV input
chaliy Oct 26, 2026
83a058e
chore: restart branch after #21 squash-merge
chaliy Oct 27, 2026
1340655
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
911360a
feat(formats): add Jupyter notebook input
chaliy Oct 26, 2026
76ea39d
chore: restart branch after #44 squash-merge
chaliy Oct 27, 2026
68ab6e7
fix(limits): enforce output cap and timeout in every feature set
chaliy Oct 27, 2026
a84a1e3
Merge remote-tracking branch 'origin/main' into pr50
chaliy Oct 27, 2026
03b3e3f
Merge remote-tracking branch 'origin/main' into pr50
chaliy Oct 27, 2026
3942d5e
Merge remote-tracking branch 'origin/claude/project-thread-3z7tjs' in…
chaliy Oct 27, 2026
2af2a1d
feat(formats): add email (EML) input
chaliy Oct 26, 2026
3b15b45
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ jobs:
fi
# Always-on formats (text, CSV, notebooks) must keep every limit with no format feature.
cargo test -p markkit --no-default-features
cargo test -p markkit --no-default-features --features docx,spreadsheet,markdown
if cargo tree -p markkit --no-default-features --features docx,spreadsheet,markdown -e normal | grep -E 'reqwest|hyper'; then
echo "HTTP client leaked into the docx,spreadsheet,markdown feature set"
cargo test -p markkit --no-default-features --features docx,spreadsheet,markdown,email
if cargo tree -p markkit --no-default-features --features docx,spreadsheet,markdown,email -e normal | grep -E 'reqwest|hyper'; then
echo "HTTP client leaked into the docx,spreadsheet,markdown,email feature set"
exit 1
fi
- name: Run examples
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- `markkit mcp`: Model Context Protocol server over stdio exposing the `markkit` tool
(`tools/list`, `tools/call`). URL fetching keeps the SSRF guard; `--no-url` keeps it offline,
the `extract` limit flags and `--max-output-chars` bound each call.
- Email input (`email` feature, in `all_formats`; `.eml`, `message/rfc822`): subject as title, From /
To / Cc / Date, plain-text body (HTML alternative converted when there is no plain part) and an
attachment list. New `Format::Eml`, `Error::EmailExtract`, `ExtractionStats::attachments`, `WarningCode::EncodingProblem`.
- Jupyter notebook input (`.ipynb`, `application/x-ipynb+json`, nbformat 4), always on: Markdown
cells, code cells as fenced blocks tagged with the kernel language, text outputs and ANSI-free
tracebacks; images/HTML outputs skipped. New `Format::Notebook`, `Error::NotebookExtract` and
Expand Down
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ cfb = "0.15" # XLS (OLE2) container pre-scan
hayro-syntax = { version = "0.8", default-features = false, features = ["std"] } # PDF object/content parsing (no image decoders)
miniz_oxide = "0.9" # Bounded FlateDecode for PDF streams
pulldown-cmark = { version = "0.13", default-features = false } # Markdown input; output-safety test oracle
mail-parser = { version = "0.11", default-features = false } # EML input (RFC 5322 / MIME)

# Logging
tracing = "0.1"
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package for browsers and edge runtimes.
- **DOCX** – Headings, lists, tables, links, emphasis
- **PPTX** – One section per slide: titles, bullets, tables, links, speaker notes
- **Spreadsheets** – XLSX/XLSM, XLSB, XLS and ODS workbooks to markdown tables, one section per sheet
- **Email (EML)** – Subject, sender, recipients, date, body (plain preferred, HTML converted), attachment list
- **Jupyter notebooks** – Markdown cells, code cells with language, text outputs and tracebacks
- **CSV / TSV** – One markdown table; `;` and `|` delimiters detected, RFC 4180 quoting
- **Plain text / Markdown** – Text to paragraphs; Markdown normalized through the same IR (raw HTML never passed through)
Expand All @@ -40,6 +41,7 @@ package for browsers and edge runtimes.
| Plain text | Supported | always on |
| CSV, TSV | Supported | always on |
| Jupyter notebooks (ipynb) | Supported | always on |
| Email (EML) | Supported | `email` |
| Markdown | Supported | `markdown` |
| PDF (text layer) | Supported | `pdf` |
| DOC, PPT | Detected, rejected explicitly | – |
Expand Down Expand Up @@ -202,7 +204,8 @@ let markkit = Markkit::builder()
| `spreadsheet` | No | XLSX/XLSM, XLS, ODS conversion (calamine) |
| `pdf` | No | PDF text-layer extraction (hayro-syntax, pure Rust) |
| `markdown` | No | Markdown normalization (pulldown-cmark) |
| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown` |
| `email` | No | EML conversion (mail-parser) |
| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown`, `email` |
| `tool` | No | `markkit::tool::MarkkitTool` LLM agent tool (Everruns toolkit contract) |
| `logging` | No | Reserved: pulls in `tracing`, emits no events yet |

Expand Down
1 change: 1 addition & 0 deletions crates/markkit-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ docx = ["markkit/docx"]
pptx = ["markkit/pptx"]
spreadsheet = ["markkit/spreadsheet"]
markdown = ["markkit/markdown"]
email = ["markkit/email"]

[dependencies]
markkit = { path = "../markkit", version = "0.1.1", features = ["http_client", "tool"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/markkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ pub struct Warning {
pub struct Extraction {
pub markdown: String,
pub title: Option<String>,
/// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`).
/// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`, `eml`).
pub format: String,
pub warnings: Vec<Warning>,
pub stats: ExtractionStats,
Expand Down
6 changes: 5 additions & 1 deletion crates/markkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ docx = ["dep:zip", "dep:quick-xml"]
pptx = ["dep:zip", "dep:quick-xml"]
spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"]
markdown = ["dep:pulldown-cmark"]
email = ["dep:mail-parser"]
# Opt-in: the default build has no network stack (no reqwest/hyper/TLS).
http_client = ["dep:reqwest", "tokio/net"]
logging = ["dep:tracing"]
# LLM tool contract (`markkit::tool`): Everruns toolkit-library shape.
tool = ["dep:tower", "dep:futures-core", "dep:base64"]
# JSON Schema for the IR (`markkit::ir::json_schema()`)
json-schema = ["dep:schemars"]
all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown"]
all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown", "email"]

[dependencies]
# Async
Expand Down Expand Up @@ -63,6 +64,9 @@ miniz_oxide = { workspace = true, optional = true }
# Markdown (optional)
pulldown-cmark = { workspace = true, optional = true }

# Email (optional)
mail-parser = { workspace = true, optional = true }

# HTTP client (optional)
reqwest = { workspace = true, optional = true }

Expand Down
234 changes: 234 additions & 0 deletions crates/markkit/src/eml.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
//! Email (`.eml`, RFC 5322 / MIME) input via mail-parser.
//!
//! Decisions:
//! - Behind the `email` feature (mail-parser 0.11, no default features: UTF-*,
//! Latin and other single-byte charsets, QP/base64, RFC 2047 words).
//! Selected by `message/rfc822` or `.eml`, never sniffed.
//! - Output: `# Subject`, a header paragraph (From, To, Cc, Date as RFC 3339),
//! then the body, then an `## Attachments` list (name, type, size). The body
//! follows RFC 8621 `textBody`: text/plain when present, otherwise the HTML
//! alternative. Plain parts go through the plain-text path; HTML parts
//! through the HTML parser (`html` feature) or mail-parser's HTML-to-text.
//! - Attachments, including nested `message/rfc822`, are listed, never parsed,
//! so extraction cost does not compound with nesting.
//! - Header values are text: control characters dropped and newlines folded.
//! - Shares the call's `Budget`: the deadline is checked after mail-parser
//! (not interruptible) and per attachment; body parts use the same budget.

use std::borrow::Cow;

use mail_parser::{Address, MessageParser, MimeHeaders, PartType};

use crate::config::MarkkitBuilder;
use crate::error::Error;
use crate::extract::{Warning, WarningCode};
use crate::ir::{Document, ListItem, Node};
use crate::limits::Budget;

/// Parsed email.
pub(crate) struct EmailOutput {
pub doc: Document,
pub title: Option<String>,
pub warnings: Vec<Warning>,
pub attachments: usize,
}

/// Parse raw message bytes into the IR.
pub(crate) fn parse(
bytes: &[u8],
config: &MarkkitBuilder,
budget: &Budget,
) -> Result<EmailOutput, Error> {
let msg = MessageParser::default()
.parse(bytes)
.filter(|m| !m.parts.is_empty())
.ok_or_else(|| Error::EmailExtract("not an RFC 5322 message".into()))?;
budget.check_time()?;
budget.add_nodes(msg.parts.len())?;
let mut nodes = Vec::new();
let mut warnings = Vec::new();

let title = msg.subject().map(header_text).filter(|s| !s.is_empty());
if let Some(subject) = &title {
nodes.push(Node::heading(1, vec![Node::Text(subject.clone())]));
}

let mut header = Vec::new();
for (label, value) in [
("From", msg.from().map(addresses)),
("To", msg.to().map(addresses)),
("Cc", msg.cc().map(addresses)),
("Date", msg.date().map(|d| d.to_rfc3339())),
] {
let Some(value) = value.filter(|v| !v.is_empty()) else {
continue;
};
if !header.is_empty() {
header.push(Node::LineBreak);
}
header.push(Node::Bold {
children: vec![Node::Text(format!("{label}:"))],
});
header.push(Node::Text(format!(" {value}")));
}
if !header.is_empty() {
nodes.push(Node::paragraph(header));
}

for (i, &id) in msg.text_body.iter().enumerate() {
let Some(part) = msg.part(id) else { continue };
if part.is_encoding_problem {
warnings.push(Warning::new(
WarningCode::EncodingProblem,
"a body part could not be fully decoded",
));
}
match &part.body {
PartType::Text(text) => {
let decoded = crate::text::decode(text.as_bytes());
nodes.extend(crate::text::plain_to_ir(&decoded.text, budget)?.0.nodes);
}
PartType::Html(html) => nodes.extend(html_nodes(html, &msg, i, config, budget)?),
_ => {}
}
}

let mut items = Vec::new();
for part in msg.attachments() {
if budget.expired() {
return Err(budget.timeout_error());
}
let name = match &part.body {
PartType::Message(m) => m.subject().map(|s| format!("{} (message)", header_text(s))),
_ => None,
}
.or_else(|| part.attachment_name().map(header_text))
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "unnamed".to_string());
let ctype = part
.content_type()
.map(|ct| match ct.subtype() {
Some(sub) => format!("{}/{}", ct.ctype(), sub),
None => ct.ctype().to_string(),
})
.map(|t| header_text(&t))
.unwrap_or_else(|| "application/octet-stream".into());
items.push(ListItem::new(vec![Node::Text(format!(
"{name} ({ctype}, {})",
human_size(part.len())
))]));
}
let attachments = items.len();
if !items.is_empty() {
nodes.push(Node::heading(2, vec![Node::Text("Attachments".into())]));
nodes.push(Node::List {
ordered: false,
start: None,
items,
source: None,
});
}

Ok(EmailOutput {
doc: Document::new(nodes),
title,
warnings,
attachments,
})
}

#[cfg(feature = "html")]
fn html_nodes(
html: &str,
_msg: &mail_parser::Message<'_>,
_pos: usize,
config: &MarkkitBuilder,
budget: &Budget,
) -> Result<Vec<Node>, Error> {
Ok(crate::html::HtmlParser::with_budget(config, budget)
.parse(html)?
.nodes)
}

#[cfg(not(feature = "html"))]
fn html_nodes(
_html: &str,
msg: &mail_parser::Message<'_>,
pos: usize,
_config: &MarkkitBuilder,
budget: &Budget,
) -> Result<Vec<Node>, Error> {
let text = msg.body_text(pos).unwrap_or(Cow::Borrowed(""));
Ok(
crate::text::plain_to_ir(&crate::text::decode(text.as_bytes()).text, budget)?
.0
.nodes,
)
}

/// Header text: controls dropped, line breaks folded, trimmed.
fn header_text(s: &str) -> String {
let t = crate::text::decode(s.as_bytes()).text;
t.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn addresses(addr: &Address<'_>) -> String {
let one = |name: Option<&Cow<'_, str>>, address: Option<&Cow<'_, str>>| {
let name = name.map(|n| header_text(n)).filter(|n| !n.is_empty());
let address = address.map(|a| header_text(a)).filter(|a| !a.is_empty());
match (name, address) {
(Some(n), Some(a)) if n != a => format!("{n} <{a}>"),
(_, Some(a)) => a,
(Some(n), None) => n,
(None, None) => String::new(),
}
};
let list: Vec<String> = match addr {
Address::List(list) => list
.iter()
.map(|a| one(a.name.as_ref(), a.address.as_ref()))
.collect(),
Address::Group(groups) => groups
.iter()
.flat_map(|g| g.addresses.iter())
.map(|a| one(a.name.as_ref(), a.address.as_ref()))
.collect(),
};
list.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ")
}

fn human_size(bytes: usize) -> String {
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{size:.1} {}", UNITS[unit])
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn sizes() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1023), "1023 B");
assert_eq!(human_size(1536), "1.5 KB");
assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
}

#[test]
fn header_folding() {
assert_eq!(header_text(" a\r\n\tb\u{7} c "), "a b c");
}
}
3 changes: 3 additions & 0 deletions crates/markkit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pub enum Error {
#[error("Notebook extraction error: {0}")]
NotebookExtract(String),

#[error("Email extraction error: {0}")]
EmailExtract(String),

#[error("HTTP error: {0}")]
Http(String),

Expand Down
Loading
Loading