email-thread-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """MCP tools for the AR email agent: thread parsing (request preprocessing) and output validation."""
@@ -0,0 +1,141 @@
1
+ """HTML email body -> plain text that keeps the line structure.
2
+
3
+ Line structure matters here: quote headers (`From:` / `Sent:` lines), greetings and sign-off blocks
4
+ are all detected line by line, so block elements and `<br>` become line breaks rather than spaces.
5
+
6
+ Text a human reader cannot see (hidden by CSS, zero-width or bidirectional control characters) is
7
+ dropped: it is the usual way to put instructions in an email for the LLM but not for the reviewer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from html.parser import HTMLParser
14
+
15
+ _BLOCK_TAGS = {
16
+ "address", "article", "blockquote", "br", "dd", "div", "dl", "dt", "footer", "h1", "h2", "h3",
17
+ "h4", "h5", "h6", "header", "hr", "li", "ol", "p", "pre", "section", "table", "tr", "ul",
18
+ }
19
+ _CELL_TAGS = {"td", "th"}
20
+ _SKIP_TAGS = {"head", "script", "style", "title"}
21
+ _VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
22
+ _HIDDEN_STYLE = re.compile(
23
+ r"display\s*:\s*none|visibility\s*:\s*hidden|mso-hide\s*:\s*all"
24
+ r"|(?<![\w-])font-size\s*:\s*0*\.?0+(px|pt|em|rem|%)?\s*(;|!|$)|(?<![\w-])opacity\s*:\s*0*\.?0+\s*(;|!|$)",
25
+ re.IGNORECASE,
26
+ )
27
+ # Zero-width, bidirectional-control and other invisible format characters, plus C0 controls but tab/newline.
28
+ _INVISIBLE = re.compile("[\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\x01-\x08\x0b\x0c\x0e-\x1f\x7f]")
29
+
30
+ # Line emitted where a mail client marks the start of quoted history. The markup is the same in every
31
+ # UI language, so the thread parser can split on it even when it knows none of the header words.
32
+ QUOTE_MARKER = "\x00quote\x00"
33
+
34
+
35
+ def _is_quote_marker(tag: str, attrs: list[tuple[str, str | None]]) -> bool:
36
+ a = {k.lower(): (v or "") for k, v in attrs}
37
+ classes = a.get("class", "").split()
38
+ return (
39
+ a.get("id") == "divRplyFwdMsg" # Outlook
40
+ or "gmail_quote" in classes # Gmail
41
+ or "moz-cite-prefix" in classes # Thunderbird
42
+ or (tag == "blockquote" and a.get("type", "").lower() == "cite") # Apple Mail, Thunderbird
43
+ )
44
+
45
+
46
+ def _is_hidden(attrs: list[tuple[str, str | None]]) -> bool:
47
+ return any(k == "hidden" or (k == "style" and v and _HIDDEN_STYLE.search(v)) for k, v in attrs)
48
+
49
+
50
+ class _TextExtractor(HTMLParser):
51
+ def __init__(self, drop_hidden: bool = True) -> None:
52
+ super().__init__(convert_charrefs=True)
53
+ self._parts: list[str] = []
54
+ self._skip_depth = 0
55
+ self._drop_hidden = drop_hidden
56
+ # A hidden element is left at its own closing tag: only that tag name is counted, so unclosed
57
+ # <p>/<li> inside it cannot keep the rest of the email hidden.
58
+ self._hidden_tag: str | None = None
59
+ self._hidden_depth = 0
60
+ self.hidden_chars = 0
61
+
62
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
63
+ if tag in _SKIP_TAGS:
64
+ self._skip_depth += 1
65
+ return
66
+ if self._hidden_tag:
67
+ self._hidden_depth += tag == self._hidden_tag
68
+ return
69
+ if self._drop_hidden and tag not in _VOID_TAGS and _is_hidden(attrs):
70
+ self._hidden_tag, self._hidden_depth = tag, 1
71
+ return
72
+ if not self._skip_depth and _is_quote_marker(tag, attrs):
73
+ self._parts.append(f"\n{QUOTE_MARKER}\n")
74
+ if tag in _BLOCK_TAGS:
75
+ self._parts.append("\n")
76
+ elif tag in _CELL_TAGS:
77
+ self._parts.append("\t")
78
+
79
+ def handle_endtag(self, tag: str) -> None:
80
+ if tag in _SKIP_TAGS:
81
+ self._skip_depth = max(0, self._skip_depth - 1)
82
+ elif self._hidden_tag:
83
+ if tag == self._hidden_tag:
84
+ self._hidden_depth -= 1
85
+ if not self._hidden_depth:
86
+ self._hidden_tag = None
87
+ elif tag in _BLOCK_TAGS:
88
+ self._parts.append("\n")
89
+
90
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
91
+ if tag in _BLOCK_TAGS and not self._hidden_tag:
92
+ self._parts.append("\n")
93
+
94
+ def handle_data(self, data: str) -> None:
95
+ if self._skip_depth:
96
+ return
97
+ if self._hidden_tag:
98
+ self.hidden_chars += len(data.strip())
99
+ return
100
+ self._parts.append(data)
101
+
102
+ def text(self) -> str:
103
+ return "".join(self._parts)
104
+
105
+
106
+ def looks_like_html(body: str) -> bool:
107
+ return bool(re.search(r"<(html|body|div|p|br|span|table)[\s/>]", body, re.IGNORECASE))
108
+
109
+
110
+ def html_to_text(body: str) -> tuple[str, int]:
111
+ """Visible text of an HTML body, and how many characters of hidden text were dropped.
112
+
113
+ If the whole body is hidden (broken markup rather than an attack), the hidden text is kept: losing
114
+ the message would be worse, and the caller still sees the count."""
115
+ parser = _TextExtractor()
116
+ parser.feed(body)
117
+ parser.close()
118
+ text = normalise_text(parser.text())
119
+ if not text.replace(QUOTE_MARKER, "").strip() and parser.hidden_chars:
120
+ everything = _TextExtractor(drop_hidden=False)
121
+ everything.feed(body)
122
+ everything.close()
123
+ return normalise_text(everything.text()), parser.hidden_chars
124
+ return text, parser.hidden_chars
125
+
126
+
127
+ def strip_invisible(text: str) -> tuple[str, int]:
128
+ """Remove characters that change what an LLM reads without changing what a person sees."""
129
+ cleaned, count = _INVISIBLE.subn("", text)
130
+ return cleaned, count
131
+
132
+
133
+ def normalise_text(text: str) -> str:
134
+ """Unify whitespace: no-break spaces become spaces, each line is trimmed, blank runs collapse."""
135
+ text = text.replace("\r\n", "\n").replace("\r", "\n").replace(" ", " ")
136
+ lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.split("\n")]
137
+ out: list[str] = []
138
+ for line in lines:
139
+ if line or (out and out[-1]):
140
+ out.append(line)
141
+ return "\n".join(out).strip()
@@ -0,0 +1,85 @@
1
+ """MCP server (stdio) exposing the AR email tools. MCP clients start it with `uvx email-thread-mcp`.
2
+
3
+ Some clients render structured arguments through a template engine first, so they may arrive as
4
+ text: JSON or a Python-style repr. Both are accepted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import json
11
+ from importlib.metadata import version
12
+ from typing import Any
13
+
14
+ from mcp.server.mcpserver import MCPServer
15
+ from mcp.server.mcpserver.exceptions import ToolError
16
+
17
+ from .thread import parse_email_thread as _parse_email_thread
18
+ from .validate import validate_output as _validate_output
19
+
20
+ mcp = MCPServer("email-thread-mcp", version=version("email-thread-mcp"))
21
+
22
+
23
+ def _coerce(value: Any, name: str = "argument") -> Any:
24
+ if not isinstance(value, str):
25
+ return value
26
+ text = value.strip()
27
+ if not text:
28
+ return None
29
+ try:
30
+ return json.loads(text)
31
+ except json.JSONDecodeError:
32
+ pass
33
+ except (RecursionError, MemoryError):
34
+ raise ToolError(f"{name} is nested too deeply") from None
35
+ try:
36
+ return ast.literal_eval(text)
37
+ except (ValueError, SyntaxError, TypeError):
38
+ # ToolError, not ValueError: the SDK hides a plain exception's text from the caller.
39
+ raise ToolError(f"{name} is neither JSON nor a Python literal") from None
40
+ except (RecursionError, MemoryError):
41
+ raise ToolError(f"{name} is nested too deeply") from None
42
+
43
+
44
+ @mcp.tool()
45
+ def parse_email_thread(body: str, ar_mailboxes: list[str] | str | None = None) -> dict[str, Any]:
46
+ """Split an inbound email body (HTML or plain text, newest message on top) into the latest
47
+ message, its signer and greeting, and the quoted history (oldest first), per the thread contract.
48
+
49
+ Args:
50
+ body: the raw email body (HTML or plain text), unchanged.
51
+ ar_mailboxes: addresses (or '@domain' entries) of the AR team; quoted messages from these are
52
+ role 'recipient' (us), messages sent to them are role 'sender' (the customer).
53
+ """
54
+ mailboxes = _coerce(ar_mailboxes, "ar_mailboxes") if isinstance(ar_mailboxes, str) else ar_mailboxes
55
+ if isinstance(mailboxes, str):
56
+ mailboxes = [mailboxes]
57
+ return _parse_email_thread(body, mailboxes or [])
58
+
59
+
60
+ @mcp.tool()
61
+ def validate_output(
62
+ output: dict[str, Any] | str,
63
+ categories: list[Any] | str,
64
+ thread: dict[str, Any] | str | None = None,
65
+ ) -> dict[str, Any]:
66
+ """Check the agent's output envelope: category names must come from the request's list,
67
+ invoice numbers clean, the draft greets the right person and marks promised attachments.
68
+ Safe corrections are applied to the returned `output`; everything else is reported in `issues`.
69
+
70
+ Args:
71
+ output: the final envelope (classification, draft_response, extracted_data, ...).
72
+ categories: the allowed categories (objects with `name`, or plain names).
73
+ thread: the parse_email_thread result, used for the greeting check. Optional.
74
+ """
75
+ return _validate_output(
76
+ _coerce(output, "output"), _coerce(categories, "categories") or [], _coerce(thread, "thread")
77
+ )
78
+
79
+
80
+ def main() -> None:
81
+ mcp.run()
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,565 @@
1
+ """Split an inbound email body into the latest message and its quoted history.
2
+
3
+ Output shape: contracts/thread.schema.json (schema_version 1.0).
4
+
5
+ The body arrives as raw HTML (Outlook, Gmail, Apple Mail) or plain text, newest message on top.
6
+ It is converted to text with line structure kept, then cut at every quote boundary:
7
+
8
+ - a mail client's HTML quote marker (see html_text.QUOTE_MARKER) - works in any UI language,
9
+ - a header block of known labels: a `From:` line followed within a few lines by `Sent:`/`Date:`,
10
+ - any `label: value` block that starts with an address and holds a date with a time - unknown languages,
11
+ - `-----Original Message-----` and forwarded-message separators,
12
+ - attribution lines (`On <date>, <name> wrote:`, `Am <date> schrieb <name>:`, ...).
13
+
14
+ Words per language live in vocab.py. A missed quote boundary is the harmful failure (old history read
15
+ as the new message), so boundaries never depend on words alone; names and dates degrade to null.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from contextvars import ContextVar
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime
24
+ from typing import Any
25
+
26
+ import dateparser
27
+
28
+ from . import vocab
29
+ from .html_text import QUOTE_MARKER, html_to_text, looks_like_html, normalise_text, strip_invisible
30
+
31
+ SCHEMA_VERSION = "1.0"
32
+
33
+ # Bounds on attacker-sized input: the body is customer-controlled text.
34
+ _MAX_RAW_CHARS = 2_000_000
35
+ _MAX_TEXT_CHARS = 300_000
36
+
37
+
38
+ def _alternation(words: list[str]) -> str:
39
+ return "|".join(re.escape(w).replace(r"\ ", r"\s+") for w in sorted(words, key=lambda w: (-len(w), w)))
40
+
41
+
42
+ _LABELS = vocab.header_labels()
43
+ _LABEL_LANGUAGES = vocab.label_languages()
44
+ _FROM_LABELS = [label for label, key in _LABELS.items() if key == "from"]
45
+ _SENT_LABELS = [label for label, key in _LABELS.items() if key == "sent"]
46
+
47
+ _HEADER_FROM = re.compile(rf"^({_alternation(_FROM_LABELS)})\s*:\s*(?P<value>.*)$", re.IGNORECASE)
48
+ _HEADER_DATE = re.compile(rf"^({_alternation(_SENT_LABELS)})\s*:", re.IGNORECASE)
49
+ _HEADER_LINE = re.compile(rf"^(?P<key>{_alternation(list(_LABELS))})\s*:\s*(?P<value>.*)$", re.IGNORECASE)
50
+ _SEPARATOR = re.compile(rf"^-{{2,}}\s*({_alternation(vocab.all_words('separators'))})\s*-{{2,}}$", re.IGNORECASE)
51
+ _WROTE_VERB = re.compile(rf"(?<!\w)({_alternation(vocab.all_words('wrote'))})(?!\w)", re.IGNORECASE)
52
+ _HEADER_LOOKAHEAD = 5
53
+ _MIN_KNOWN_HEADERS = 3
54
+
55
+ # Any-language header line: a label of 1-3 words of letters, then a value.
56
+ _LABEL_LINE = re.compile(r"^(?P<label>[^\W\d_]+(?:[ .'-][^\W\d_]+){0,2})\s*:\s*(?P<value>\S.*)$")
57
+ _LABEL_MAX = 25
58
+ _GENERIC_HEADERS_MIN, _GENERIC_HEADERS_MAX = 3, 8
59
+ _TIME = re.compile(r"(?<!\d)\d{1,2}[:.]\d{2}(?!\d)")
60
+ _NUMERIC_DATE = re.compile(r"\d{1,4}[./-]\d{1,2}[./-]\d{2,4},?(\s+\d{1,2}[:.]\d{2})?")
61
+ _YEAR = re.compile(r"(?<!\d)(19|20)\d{2}(?!\d)")
62
+
63
+ _GREETING = re.compile(
64
+ rf"^({_alternation(vocab.all_words('greetings'))})(?!\w)[\s,]*(?P<name>[^,!:]*?)\s*[,!:]?$",
65
+ re.IGNORECASE,
66
+ )
67
+ _HONORIFIC = re.compile(rf"^({_alternation(vocab.all_words('honorifics'))})\.?(\s+|$)", re.IGNORECASE)
68
+ _CLOSING = re.compile(
69
+ rf"^({_alternation(vocab.all_words('closings'))})(?!\w)\s*[,.!-]?\s*(?P<rest>.*)$",
70
+ re.IGNORECASE,
71
+ )
72
+ _NAME_PARTICLES = {"von", "van", "der", "de", "da", "di", "le", "la", "du", "del", "den", "ten", "zu"}
73
+ _NOT_NAME_WORDS = {
74
+ "accounts", "payable", "receivable", "department", "team", "finance", "procurement", "gmbh", "ltd",
75
+ "inc", "ag", "ab", "llc", "co", "corp", "company", "credit", "collections", "manager", "sent",
76
+ "from", "iphone", "office", "support", "customer", "service", "bv", "nv", "sa", "srl", "spa", "sas",
77
+ "sarl", "oy", "oyj", "aps", "kg", "se",
78
+ }
79
+ # Bounded parts keep the search linear on long runs of word characters without an '@'.
80
+ _EMAIL = re.compile(r"[\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,8}")
81
+ _NAME_BEFORE_ADDRESS = re.compile(r"((?:[^\W\d_][\w.'’-]*\s+){0,4})(<[^<>\s]+@[^<>\s]+>)")
82
+ # Words that sit between an attribution's date and the sender's name ("at 9:15 AM Anna Keller").
83
+ _ATTRIBUTION_FILLERS = {"am", "pm", "at", "um", "à", "om", "alle", "a", "las", "às", "o", "kl", "klo", "v"}
84
+
85
+
86
+ @dataclass
87
+ class Message:
88
+ body: str
89
+ headers: dict[str, str] = field(default_factory=dict)
90
+ role: str = "unknown"
91
+ greeting_name: str | None = None
92
+ signer_name: str | None = None
93
+ signature: str | None = None
94
+ text_without_signature: str = ""
95
+ sent_at: str | None = None
96
+ notes: list[str] = field(default_factory=list)
97
+
98
+
99
+ # --------------------------------------------------------------------------------------------- #
100
+ # Splitting
101
+ # --------------------------------------------------------------------------------------------- #
102
+
103
+ _OUTLOOK_BOLD_FROM = re.compile(rf"<b>\s*({_alternation(_FROM_LABELS)}):\s*</b>", re.IGNORECASE)
104
+
105
+
106
+ def detect_format(raw: str, quoted_messages: int) -> str:
107
+ if looks_like_html(raw):
108
+ if re.search(r'id="divRplyFwdMsg"|border-top:\s*solid', raw, re.IGNORECASE) or _OUTLOOK_BOLD_FROM.search(raw):
109
+ return "outlook-html"
110
+ if "gmail_quote" in raw:
111
+ return "gmail-html"
112
+ if re.search(r'<blockquote[^>]*type="cite"', raw, re.IGNORECASE):
113
+ return "apple-html"
114
+ return "html-unknown"
115
+ return "plain-text" if quoted_messages else "plain-text-no-quotes"
116
+
117
+
118
+ def _is_wrote(line: str) -> bool:
119
+ """Attribution line: a 'wrote' verb plus an address or a year, ending the line (any language)."""
120
+ if len(line) > 300 or not _WROTE_VERB.search(line):
121
+ return False
122
+ if not (_EMAIL.search(line) or _YEAR.search(line)):
123
+ return False
124
+ return line.rstrip().endswith(":") or bool(_WROTE_VERB.search(line.rstrip()[-20:]))
125
+
126
+
127
+ def _is_marker_attribution(line: str) -> bool:
128
+ """Line right after an HTML quote marker that ends in ':' and names an address: an attribution in a
129
+ language whose 'wrote' verb is not in the vocabulary."""
130
+ return len(line) <= 300 and line.rstrip().endswith(":") and bool(_EMAIL.search(line))
131
+
132
+
133
+ def _known_header_block(lines: list[str], i: int) -> bool:
134
+ if not _HEADER_FROM.match(lines[i]):
135
+ return False
136
+ window = lines[i : i + 1 + 2 * _HEADER_LOOKAHEAD] # header lines may be separated by blanks
137
+ if not any(_HEADER_DATE.match(w) for w in window[1:]):
138
+ return False
139
+ return sum(1 for w in window if _HEADER_LINE.match(w)) >= _MIN_KNOWN_HEADERS
140
+
141
+
142
+ def _label_line(line: str) -> re.Match[str] | None:
143
+ m = _LABEL_LINE.match(line)
144
+ return m if m and len(m.group("label")) <= _LABEL_MAX else None
145
+
146
+
147
+ def _generic_header_block(lines: list[str], i: int) -> list[int] | None:
148
+ """Indexes of a `label: value` block starting at i, whatever the labels' language, or None.
149
+
150
+ The first value must carry an address (the sender) and a later one a date with a time of day:
151
+ every mail client's quote header has both, a remittance table in the body rarely does.
152
+ """
153
+ first = _label_line(lines[i])
154
+ if not first or not _EMAIL.search(first.group("value")) or len(first.group("value")) > 150:
155
+ return None
156
+ found = [i]
157
+ j = i + 1
158
+ while j < len(lines) and len(found) < _GENERIC_HEADERS_MAX:
159
+ if not lines[j]:
160
+ j += 1
161
+ continue
162
+ if not _label_line(lines[j]):
163
+ break
164
+ found.append(j)
165
+ j += 1
166
+ if len(found) < _GENERIC_HEADERS_MIN:
167
+ return None
168
+ values = [_label_line(lines[k]).group("value") for k in found[1:]] # type: ignore[union-attr]
169
+ if not any(_TIME.search(v) and "@" not in v and parse_sent(v) for v in values):
170
+ return None
171
+ return found
172
+
173
+
174
+ def _boundaries(lines: list[str]) -> list[int]:
175
+ """Line indexes where a quoted message starts (its marker, header, separator or attribution line)."""
176
+ starts: list[int] = []
177
+ i = 0
178
+ while i < len(lines):
179
+ line = lines[i]
180
+ is_start = (
181
+ line == QUOTE_MARKER
182
+ or bool(_SEPARATOR.match(line))
183
+ or _is_wrote(line)
184
+ or _known_header_block(lines, i)
185
+ or _generic_header_block(lines, i) is not None
186
+ or (i + 1 < len(lines) and _is_wrote(f"{line} {lines[i + 1]}")) # attribution wrapped over two lines
187
+ )
188
+ if not is_start:
189
+ i += 1
190
+ continue
191
+ starts.append(i)
192
+ # Marker, separator, attribution and header block of one quoted message: one boundary, not several.
193
+ i += 1
194
+ while i < len(lines):
195
+ block = _generic_header_block(lines, i)
196
+ if block:
197
+ i = block[-1] + 1
198
+ elif (
199
+ not lines[i]
200
+ or lines[i] == QUOTE_MARKER
201
+ or _SEPARATOR.match(lines[i])
202
+ or _HEADER_LINE.match(lines[i])
203
+ or _is_wrote(lines[i])
204
+ or _is_marker_attribution(lines[i])
205
+ ):
206
+ i += 1
207
+ else:
208
+ break
209
+ return starts
210
+
211
+
212
+ def _attribution_from(line: str) -> str | None:
213
+ """Sender out of an attribution line: 'Name <address>', else the address, else the name before the verb."""
214
+ m = _NAME_BEFORE_ADDRESS.search(line)
215
+ if m:
216
+ tokens = m.group(1).split()
217
+ while tokens and (
218
+ tokens[0].lower() in _ATTRIBUTION_FILLERS or not tokens[0][0].isupper() or _WROTE_VERB.fullmatch(tokens[0])
219
+ ):
220
+ tokens.pop(0)
221
+ return " ".join([*tokens, m.group(2)])
222
+ m = _EMAIL.search(line)
223
+ if m:
224
+ return m.group(0)
225
+ verb = _WROTE_VERB.search(line)
226
+ before = line[: verb.start()] if verb else line
227
+ name = before.rsplit(",", 1)[-1].strip(" :")
228
+ return name or None
229
+
230
+
231
+ def _attribution_date(line: str, sender: str | None) -> str | None:
232
+ text = line.replace(sender, " ") if sender else line
233
+ text = _WROTE_VERB.sub(" ", text).strip(" :,")
234
+ words = text.split()
235
+ for skip in range(3): # leading 'On' / 'Am' / 'Le' / 'W dniu' ...
236
+ if (value := parse_sent(" ".join(words[skip:]).strip(" ,"))) is not None:
237
+ return value
238
+ numeric = _NUMERIC_DATE.search(text) # words of an unknown language around a numeric date
239
+ return parse_sent(numeric.group(0)) if numeric else None
240
+
241
+
242
+ def _parse_quoted(block: list[str]) -> Message:
243
+ headers: dict[str, str] = {}
244
+ unknown_labels: list[str] = []
245
+ sent_languages: list[str] | None = None
246
+ attribution: str | None = None
247
+ marker_only = True
248
+ after_marker = False
249
+ idx = 0
250
+ while idx < len(block):
251
+ line = block[idx]
252
+ if not line or line == QUOTE_MARKER:
253
+ after_marker = after_marker or line == QUOTE_MARKER
254
+ idx += 1
255
+ continue
256
+ if _SEPARATOR.match(line):
257
+ marker_only = False
258
+ idx += 1
259
+ continue
260
+ if not headers and attribution is None:
261
+ if _is_wrote(line) or (after_marker and _is_marker_attribution(line)):
262
+ attribution = line
263
+ idx += 1
264
+ continue
265
+ if idx + 1 < len(block) and _is_wrote(f"{line} {block[idx + 1]}"):
266
+ attribution = f"{line} {block[idx + 1]}"
267
+ idx += 2
268
+ continue
269
+ m = _HEADER_LINE.match(line)
270
+ if m:
271
+ label = m.group("key").lower()
272
+ headers.setdefault(_LABELS[label], m.group("value").strip())
273
+ if _LABELS[label] == "sent":
274
+ sent_languages = _LABEL_LANGUAGES.get(label)
275
+ idx += 1
276
+ continue
277
+ generic = _generic_header_block(block, idx)
278
+ if generic:
279
+ unknown_labels += _assign_generic_headers([block[k] for k in generic], headers)
280
+ idx = generic[-1] + 1
281
+ continue
282
+ break
283
+
284
+ if attribution is not None:
285
+ sender = _attribution_from(attribution)
286
+ if sender:
287
+ headers.setdefault("from", sender)
288
+ sent_at = _attribution_date(attribution, sender)
289
+ else:
290
+ sent_at = parse_sent(headers.get("sent"), sent_languages)
291
+
292
+ message = Message(
293
+ body=normalise_text("\n".join(re.sub(r"^(>\s?)+", "", line) for line in block[idx:])),
294
+ headers=headers,
295
+ sent_at=sent_at,
296
+ )
297
+ if unknown_labels:
298
+ message.notes.append(f"header labels not recognised: {', '.join(unknown_labels)}")
299
+ if headers.get("sent") and sent_at is None:
300
+ message.notes.append(f"sent date not parsed: {headers['sent']!r}")
301
+ if marker_only and not headers and attribution is None:
302
+ message.notes.append("found by the HTML quote marker only: sender and date unknown")
303
+ return message
304
+
305
+
306
+ def _assign_generic_headers(lines: list[str], headers: dict[str, str]) -> list[str]:
307
+ """Map a header block's labels to from/to/sent/subject. Unknown labels are mapped by their value:
308
+ the first address is the sender, the next the recipient, a date the sent time, the last line the
309
+ subject. Returns the labels that were not in the vocabulary."""
310
+ unknown: list[str] = []
311
+ pairs = [(m.group("label"), m.group("value").strip()) for m in map(_label_line, lines) if m]
312
+ for n, (label, value) in enumerate(pairs):
313
+ key = _LABELS.get(label.lower())
314
+ if key is None:
315
+ unknown.append(label)
316
+ if _EMAIL.search(value):
317
+ key = "from" if "from" not in headers else "to" if "to" not in headers else "cc"
318
+ elif "sent" not in headers and _TIME.search(value) and parse_sent(value):
319
+ key = "sent"
320
+ elif n == len(pairs) - 1:
321
+ key = "subject"
322
+ else:
323
+ continue
324
+ headers.setdefault(key, value)
325
+ return unknown
326
+
327
+
328
+ # --------------------------------------------------------------------------------------------- #
329
+ # Names
330
+ # --------------------------------------------------------------------------------------------- #
331
+
332
+
333
+ def looks_like_name(text: str) -> bool:
334
+ text = text.strip().strip(",.")
335
+ if not text or len(text) > 50 or any(ch.isdigit() for ch in text) or "@" in text:
336
+ return False
337
+ tokens = text.replace("-", " ").split()
338
+ if not 1 <= len(tokens) <= 4:
339
+ return False
340
+ if any(t.lower().strip(".,") in _NOT_NAME_WORDS for t in tokens):
341
+ return False
342
+ return all(t[0].isupper() or t.lower() in _NAME_PARTICLES for t in tokens)
343
+
344
+
345
+ def find_greeting(lines: list[str]) -> str | None:
346
+ for line in lines[:3]:
347
+ m = _GREETING.match(line)
348
+ if m:
349
+ name = m.group("name").strip()
350
+ return name if name and looks_like_name(_HONORIFIC.sub("", name)) else None
351
+ return None
352
+
353
+
354
+ def find_signoff(lines: list[str]) -> tuple[int | None, str | None, str | None]:
355
+ """Return (index of the closing line, signer name, signature block) for the LAST sign-off."""
356
+ for idx in range(len(lines) - 1, -1, -1):
357
+ line = lines[idx]
358
+ if len(line) > 60:
359
+ continue
360
+ m = _CLOSING.match(line)
361
+ if not m:
362
+ continue
363
+ rest = m.group("rest").strip()
364
+ if rest and not looks_like_name(rest):
365
+ continue # "Thank you for your collaboration." is a sentence, not a sign-off
366
+ after = [l for l in lines[idx + 1 : idx + 9] if l]
367
+ signer = rest or (after[0] if after and looks_like_name(after[0]) else None)
368
+ signature_lines = ([rest] if rest else []) + after
369
+ return idx, signer, "\n".join(signature_lines) or None
370
+ return None, None, None
371
+
372
+
373
+ def _annotate(message: Message, lines: list[str]) -> None:
374
+ message.greeting_name = find_greeting(lines)
375
+ idx, message.signer_name, message.signature = find_signoff(lines)
376
+ message.text_without_signature = normalise_text("\n".join(lines[:idx] if idx is not None else lines))
377
+
378
+
379
+ # --------------------------------------------------------------------------------------------- #
380
+ # Roles, dates, reply-to
381
+ # --------------------------------------------------------------------------------------------- #
382
+
383
+
384
+ def _addresses(value: str | None) -> set[str]:
385
+ return {m.group(0).lower() for m in _EMAIL.finditer(value or "")}
386
+
387
+
388
+ def assign_role(headers: dict[str, str], ar_mailboxes: list[str]) -> str:
389
+ """recipient = written by the AR team (us); sender = written by the customer side."""
390
+ if not ar_mailboxes:
391
+ return "unknown"
392
+
393
+ def is_ours(addresses: set[str]) -> bool:
394
+ for entry in (e.lower().strip() for e in ar_mailboxes):
395
+ if any(a == entry or (entry.startswith("@") and a.endswith(entry)) for a in addresses):
396
+ return True
397
+ return False
398
+
399
+ if is_ours(_addresses(headers.get("from"))):
400
+ return "recipient"
401
+ if is_ours(_addresses(headers.get("to"))):
402
+ return "sender"
403
+ return "unknown"
404
+
405
+
406
+ # Outlook's English forms, parsed without dateparser's start-up cost.
407
+ _DATE_FORMATS = (
408
+ "%A, %B %d, %Y %I:%M %p",
409
+ "%A, %B %d, %Y %H:%M",
410
+ "%d.%m.%Y %H:%M",
411
+ "%Y-%m-%d %H:%M",
412
+ )
413
+ # Absolute dates only: a relative word ("yesterday") must not become today's date, and a missing year
414
+ # must not be filled in.
415
+ _MAX_DATE_CHARS = 100 # a header date is ~50 chars; dateparser's cost grows with the text
416
+ _DATEPARSER_CALLS = 50 # per parse_email_thread call: real threads need a few, crafted bodies thousands
417
+ _date_budget: ContextVar[list[int] | None] = ContextVar("_date_budget", default=None) # [calls left, refused]
418
+ _date_cache: dict[tuple[str, tuple[str, ...] | None, str], str | None] = {}
419
+ _DATEPARSER_SETTINGS = {
420
+ "PARSERS": ["timestamp", "absolute-time"],
421
+ "REQUIRE_PARTS": ["day", "month", "year"],
422
+ "RETURN_AS_TIMEZONE_AWARE": False,
423
+ }
424
+
425
+
426
+ def parse_sent(value: str | None, languages: list[str] | None = None) -> str | None:
427
+ """A quote header's date as ISO 8601, in any of dateparser's languages; None when not parseable."""
428
+ if not value:
429
+ return None
430
+ text = value.strip()
431
+ for fmt in _DATE_FORMATS:
432
+ try:
433
+ return datetime.strptime(text, fmt).isoformat()
434
+ except ValueError:
435
+ continue
436
+ if len(text) > _MAX_DATE_CHARS:
437
+ return None
438
+ order = "MDY" if re.search(r"\b[ap]\.?m\.?\b", text, re.IGNORECASE) else "DMY"
439
+ key = (text, tuple(languages) if languages else None, order)
440
+ if key in _date_cache:
441
+ return _date_cache[key]
442
+ budget = _date_budget.get()
443
+ if budget is not None:
444
+ if budget[0] <= 0:
445
+ budget[1] += 1
446
+ return None
447
+ budget[0] -= 1
448
+ try:
449
+ parsed = dateparser.parse(text, languages=languages, settings={**_DATEPARSER_SETTINGS, "DATE_ORDER": order}) # type: ignore[arg-type]
450
+ except Exception: # dateparser raises assorted errors on odd input; a date is never worth failing the parse
451
+ parsed = None
452
+ if len(_date_cache) > 4096:
453
+ _date_cache.clear()
454
+ _date_cache[key] = parsed.isoformat() if parsed else None
455
+ return _date_cache[key]
456
+
457
+
458
+ def resolve_reply_to_name(latest: Message, history_newest_first: list[Message]) -> str | None:
459
+ """Who the draft reply should greet.
460
+
461
+ Agreed order:
462
+ 1. the signer of the latest message;
463
+ 2. else the name OUR most recent quoted message greeted (the customer replied to it);
464
+ 3. else None - the drafter then uses a neutral greeting. Never guess a name.
465
+
466
+ Step 2 trusts only a message positively identified as ours (role "recipient", which needs
467
+ ar_mailboxes). With unknown roles the greeting could be our own colleague's name, so it is skipped.
468
+ """
469
+ if latest.signer_name:
470
+ return latest.signer_name
471
+ ours = next((m for m in history_newest_first if m.role == "recipient"), None)
472
+ return ours.greeting_name if ours else None
473
+
474
+
475
+ # --------------------------------------------------------------------------------------------- #
476
+ # Entry point
477
+ # --------------------------------------------------------------------------------------------- #
478
+
479
+
480
+ def parse_email_thread(body: str, ar_mailboxes: list[str] | None = None) -> dict[str, Any]:
481
+ budget = [_DATEPARSER_CALLS, 0]
482
+ token = _date_budget.set(budget)
483
+ try:
484
+ result = _parse_email_thread(body, ar_mailboxes)
485
+ finally:
486
+ _date_budget.reset(token)
487
+ if budget[1]:
488
+ result["parse_info"]["warnings"].append("date parsing limit reached: some dates left null")
489
+ return result
490
+
491
+
492
+ def _parse_email_thread(body: str, ar_mailboxes: list[str] | None) -> dict[str, Any]:
493
+ warnings: list[str] = []
494
+ raw = (body or "").replace("\x00", "") # NUL is the quote marker's delimiter; the body cannot forge one
495
+ if len(raw) > _MAX_RAW_CHARS:
496
+ raw = raw[:_MAX_RAW_CHARS]
497
+ warnings.append(f"body truncated to {_MAX_RAW_CHARS} characters before parsing")
498
+ if looks_like_html(raw):
499
+ text, hidden_chars = html_to_text(raw)
500
+ if hidden_chars:
501
+ warnings.append(f"hidden HTML text removed: {hidden_chars} characters a reader would not see")
502
+ else:
503
+ text = normalise_text(raw)
504
+ text, invisible = strip_invisible(text)
505
+ if invisible:
506
+ warnings.append(f"invisible characters removed: {invisible} (zero-width, bidirectional or control)")
507
+ if len(text) > _MAX_TEXT_CHARS:
508
+ text = text[:_MAX_TEXT_CHARS]
509
+ warnings.append(f"text truncated to {_MAX_TEXT_CHARS} characters; the end of the quoted history is lost")
510
+ lines = text.split("\n")
511
+ starts = _boundaries(lines)
512
+
513
+ latest_lines = [l for l in lines[: starts[0]] if l and l != QUOTE_MARKER] if starts else [l for l in lines if l and l != QUOTE_MARKER]
514
+ latest = Message(body=normalise_text("\n".join(latest_lines)))
515
+ _annotate(latest, latest_lines)
516
+
517
+ quoted: list[Message] = []
518
+ for n, start in enumerate(starts):
519
+ end = starts[n + 1] if n + 1 < len(starts) else len(lines)
520
+ msg = _parse_quoted(lines[start:end])
521
+ msg.body = normalise_text("\n".join(l for l in msg.body.split("\n") if l != QUOTE_MARKER))
522
+ if not msg.body:
523
+ warnings.append(f"quoted message {n + 1} has no body text")
524
+ continue
525
+ msg_lines = [l for l in msg.body.split("\n") if l]
526
+ _annotate(msg, msg_lines)
527
+ msg.role = assign_role(msg.headers, ar_mailboxes or [])
528
+ warnings += [f"quoted message {n + 1}: {note}" for note in msg.notes]
529
+ quoted.append(msg)
530
+
531
+ if not starts:
532
+ warnings.append("no quote marker found: whole body treated as the latest message")
533
+ if latest.signer_name is None:
534
+ warnings.append("latest message: no signer name found in the sign-off")
535
+ if not latest.text_without_signature:
536
+ warnings.append("latest message has no text besides greeting/signature")
537
+
538
+ return {
539
+ "schema_version": SCHEMA_VERSION,
540
+ "latest_message": {
541
+ "text": latest.text_without_signature or latest.body,
542
+ "greeting_name": latest.greeting_name,
543
+ "signer_name": latest.signer_name,
544
+ "signature": latest.signature,
545
+ },
546
+ "thread_context": [
547
+ {
548
+ "role": m.role,
549
+ "from": m.headers.get("from"),
550
+ "to": m.headers.get("to"),
551
+ "sent_at": m.sent_at,
552
+ "subject": m.headers.get("subject"),
553
+ "greeting_name": m.greeting_name,
554
+ "signer_name": m.signer_name,
555
+ "body": m.body,
556
+ }
557
+ for m in reversed(quoted) # contract: oldest first
558
+ ],
559
+ "reply_to_name": resolve_reply_to_name(latest, quoted),
560
+ "parse_info": {
561
+ "format": detect_format(raw, len(quoted)),
562
+ "quoted_messages": len(quoted),
563
+ "warnings": warnings,
564
+ },
565
+ }
@@ -0,0 +1,146 @@
1
+ """Deterministic checks of the agent's output envelope.
2
+
3
+ Output shape: contracts/validation.schema.json (schema_version 1.0).
4
+
5
+ Corrects only what is unambiguous and reports the rest. In particular it never maps a category by
6
+ similarity or prefix: "Invoice" is not silently turned into "Copy of Invoice".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import re
13
+ import unicodedata
14
+ from typing import Any
15
+
16
+ SCHEMA_VERSION = "1.0"
17
+
18
+ _DATE_LIKE = re.compile(r"^\d{1,2}[./-]\d{1,2}[./-]\d{2,4}$|^\d{4}-\d{2}-\d{2}$")
19
+ _PROMISES_DOCUMENT = re.compile(
20
+ r"\b(send|attach|resend|forward|provide)\b[^.\n]{0,60}\b(copy|copies|invoice|statement|proof of delivery|pod|credit note)\b",
21
+ re.IGNORECASE,
22
+ )
23
+ _PLACEHOLDER = re.compile(r"\[[^\]]*attached\]", re.IGNORECASE)
24
+ _HONORIFIC = re.compile(r"^(mr|ms|mrs|dr|frau|herr)\.?\s+", re.IGNORECASE)
25
+
26
+
27
+ def normalise_name(name: str) -> str:
28
+ """Case, Unicode form, quotes, dashes, slashes and whitespace - nothing that changes the words."""
29
+ s = unicodedata.normalize("NFKC", name).casefold()
30
+ s = re.sub(r"[‘’“”\"']", "", s)
31
+ s = re.sub(r"[‐-―−]", "-", s)
32
+ s = re.sub(r"\s*([/-])\s*", r"\1", s)
33
+ return re.sub(r"\s+", " ", s).strip()
34
+
35
+
36
+ def _issue(check: str, severity: str, path: str, message: str, corrected: bool) -> dict[str, Any]:
37
+ return {"check": check, "severity": severity, "path": path, "message": message, "corrected": corrected}
38
+
39
+
40
+ def _category_names(categories: list[Any]) -> list[str]:
41
+ return [c["name"] if isinstance(c, dict) else str(c) for c in categories]
42
+
43
+
44
+ def _check_categories(out: dict[str, Any], allowed: list[str], issues: list[dict[str, Any]]) -> None:
45
+ cats = (out.get("classification") or {}).get("categories")
46
+ if not isinstance(cats, list):
47
+ issues.append(_issue("category_in_list", "error", "$.classification.categories", "missing categories list", False))
48
+ return
49
+ by_norm: dict[str, list[str]] = {}
50
+ for name in allowed:
51
+ by_norm.setdefault(normalise_name(name), []).append(name)
52
+
53
+ seen: set[str] = set()
54
+ kept: list[dict[str, Any]] = []
55
+ for i, item in enumerate(cats):
56
+ path = f"$.classification.categories[{i}].category"
57
+ value = item.get("category") if isinstance(item, dict) else None
58
+ if not isinstance(value, str):
59
+ issues.append(_issue("category_in_list", "error", path, "category is not a string", False))
60
+ kept.append(item)
61
+ continue
62
+ if value not in allowed:
63
+ matches = by_norm.get(normalise_name(value), [])
64
+ if len(matches) == 1:
65
+ issues.append(_issue("category_in_list", "warning", path,
66
+ f"'{value}' differs from the request's '{matches[0]}' only in case or punctuation; corrected", True))
67
+ item = {**item, "category": matches[0]}
68
+ value = matches[0]
69
+ else:
70
+ issues.append(_issue("category_in_list", "error", path,
71
+ f"'{value}' is not in the request's category list", False))
72
+ if value in seen:
73
+ issues.append(_issue("category_duplicate", "warning", path, f"'{value}' listed twice; duplicate removed", True))
74
+ continue
75
+ seen.add(value)
76
+ kept.append(item)
77
+ out["classification"]["categories"] = kept
78
+
79
+
80
+ def _check_invoices(out: dict[str, Any], issues: list[dict[str, Any]]) -> None:
81
+ data = out.get("extracted_data")
82
+ if not isinstance(data, dict):
83
+ return
84
+ invoices = data.get("invoice_numbers")
85
+ if not isinstance(invoices, list):
86
+ return
87
+ clean: list[str] = []
88
+ for i, raw in enumerate(invoices):
89
+ path = f"$.extracted_data.invoice_numbers[{i}]"
90
+ value = str(raw).strip().strip("()[]{},;:")
91
+ if value != str(raw):
92
+ issues.append(_issue("invoice_numbers", "warning", path, f"stray punctuation removed from '{raw}'", True))
93
+ if _DATE_LIKE.match(value):
94
+ issues.append(_issue("invoice_numbers", "warning", path, f"'{value}' is a date, not an invoice number; removed", True))
95
+ continue
96
+ if value in clean:
97
+ issues.append(_issue("invoice_numbers", "warning", path, f"'{value}' listed twice; duplicate removed", True))
98
+ continue
99
+ clean.append(value)
100
+ data["invoice_numbers"] = clean or None
101
+
102
+
103
+ def _check_draft(out: dict[str, Any], thread: dict[str, Any] | None, issues: list[dict[str, Any]]) -> None:
104
+ draft = out.get("draft_response")
105
+ if not isinstance(draft, dict):
106
+ return
107
+ if draft.get("requires_human_review") is not True:
108
+ draft["requires_human_review"] = True
109
+ issues.append(_issue("requires_human_review", "warning", "$.draft_response.requires_human_review",
110
+ "must always be true; set to true", True))
111
+ body = draft.get("draft_body") or ""
112
+ first_line = body.strip().split("\n", 1)[0] if body.strip() else ""
113
+
114
+ if thread:
115
+ latest = thread.get("latest_message") or {}
116
+ ours = latest.get("greeting_name")
117
+ reply_to = thread.get("reply_to_name")
118
+ if ours and _surname(ours) and _surname(ours) in first_line and (not reply_to or _surname(reply_to) != _surname(ours)):
119
+ issues.append(_issue("greeting_matches_reply_to", "warning", "$.draft_response.draft_body",
120
+ f"the draft greets '{ours}', who is the AR team member the customer wrote to", False))
121
+ elif reply_to and _surname(reply_to) and _surname(reply_to) not in first_line:
122
+ issues.append(_issue("greeting_matches_reply_to", "warning", "$.draft_response.draft_body",
123
+ f"the draft does not greet '{reply_to}', the person to reply to", False))
124
+
125
+ if _PROMISES_DOCUMENT.search(body) and not _PLACEHOLDER.search(body):
126
+ issues.append(_issue("attachment_placeholder", "warning", "$.draft_response.draft_body",
127
+ "the draft promises a document but has no '[... attached]' placeholder", False))
128
+
129
+
130
+ def _surname(name: str) -> str:
131
+ tokens = _HONORIFIC.sub("", name.strip()).split()
132
+ return tokens[-1] if tokens else ""
133
+
134
+
135
+ def validate_output(output: dict[str, Any], categories: list[Any], thread: dict[str, Any] | None = None) -> dict[str, Any]:
136
+ out = copy.deepcopy(output)
137
+ issues: list[dict[str, Any]] = []
138
+ _check_categories(out, _category_names(categories), issues)
139
+ _check_invoices(out, issues)
140
+ _check_draft(out, thread, issues)
141
+ return {
142
+ "schema_version": SCHEMA_VERSION,
143
+ "valid": not any(i["severity"] == "error" and not i["corrected"] for i in issues),
144
+ "output": out,
145
+ "issues": issues,
146
+ }
@@ -0,0 +1,202 @@
1
+ """Per-language words the thread parser looks for: quote-header labels, separators, attribution verbs,
2
+ greetings, sign-offs and honorifics.
3
+
4
+ These words only label and enrich what the parser finds. Quote boundaries are also found by structure
5
+ (HTML quote markers, a `label: value` block with an address and a date), so a language missing here
6
+ still splits correctly; only names and header labels degrade, and parse_info.warnings says so.
7
+
8
+ Adding a language: add an entry below, then a case to tests/test_languages.py. Set `verified` once a
9
+ real thread in that language (anonymised like tests/fixtures/outlook-2026-09-29) has been checked.
10
+ Keys of HEADERS are the canonical header names the parser emits. `dateparser` is the language code
11
+ dateparser expects for that language's dates.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TypedDict
17
+
18
+
19
+ class Language(TypedDict):
20
+ verified: bool
21
+ dateparser: str
22
+ headers: dict[str, list[str]]
23
+ separators: list[str]
24
+ wrote: list[str]
25
+ greetings: list[str]
26
+ closings: list[str]
27
+ honorifics: list[str]
28
+
29
+
30
+ LANGUAGES: dict[str, Language] = {
31
+ "en": {
32
+ "verified": True, # tests/fixtures/outlook-2026-09-29
33
+ "dateparser": "en",
34
+ "headers": {"from": ["from"], "sent": ["sent", "date"], "to": ["to"], "cc": ["cc", "bcc"], "subject": ["subject"]},
35
+ "separators": ["original message", "forwarded message"],
36
+ "wrote": ["wrote"],
37
+ "greetings": ["dear", "hi", "hello", "hey", "good morning", "good afternoon", "good evening"],
38
+ "closings": [
39
+ "kind regards", "best regards", "warm regards", "kindest regards", "regards", "best wishes", "best",
40
+ "thanks", "thank you", "many thanks", "sincerely", "yours sincerely", "yours faithfully",
41
+ "yours truly", "cheers",
42
+ ],
43
+ "honorifics": ["mr", "ms", "mrs", "miss", "dr"],
44
+ },
45
+ "de": {
46
+ "verified": False,
47
+ "dateparser": "de",
48
+ "headers": {"from": ["von"], "sent": ["gesendet", "datum"], "to": ["an"], "cc": ["cc", "bcc"], "subject": ["betreff"]},
49
+ "separators": ["ursprüngliche nachricht", "weitergeleitete nachricht"],
50
+ "wrote": ["schrieb"],
51
+ "greetings": ["hallo", "liebe", "lieber", "liebes", "sehr geehrte", "sehr geehrter", "guten tag", "guten morgen", "moin"],
52
+ "closings": [
53
+ "mit freundlichen grüßen", "mit freundlichen grüssen", "freundliche grüße", "freundliche grüsse",
54
+ "viele grüße", "beste grüße", "liebe grüße", "herzliche grüße", "schöne grüße", "grüße", "gruß",
55
+ "vielen dank", "danke", "mfg",
56
+ ],
57
+ "honorifics": ["herr", "frau", "hr", "fr"],
58
+ },
59
+ "fr": {
60
+ "verified": False,
61
+ "dateparser": "fr",
62
+ "headers": {"from": ["de"], "sent": ["envoyé", "date"], "to": ["à", "a"], "cc": ["cc", "cci"], "subject": ["objet"]},
63
+ "separators": ["message d'origine", "message transféré"],
64
+ "wrote": ["a écrit"],
65
+ "greetings": ["bonjour", "bonsoir", "cher", "chère", "salut"],
66
+ "closings": [
67
+ "cordialement", "bien cordialement", "bien à vous", "salutations", "meilleures salutations",
68
+ "sincères salutations", "merci", "merci beaucoup", "bonne journée",
69
+ ],
70
+ "honorifics": ["m", "mme", "mlle", "monsieur", "madame"],
71
+ },
72
+ "nl": {
73
+ "verified": False,
74
+ "dateparser": "nl",
75
+ "headers": {"from": ["van"], "sent": ["verzonden", "datum"], "to": ["aan"], "cc": ["cc", "bcc"], "subject": ["onderwerp"]},
76
+ "separators": ["oorspronkelijk bericht", "doorgestuurd bericht"],
77
+ "wrote": ["schreef"],
78
+ "greetings": ["beste", "geachte", "hallo", "hoi", "goedemorgen", "goedemiddag", "dag"],
79
+ "closings": [
80
+ "met vriendelijke groet", "met vriendelijke groeten", "vriendelijke groet", "vriendelijke groeten",
81
+ "hartelijke groet", "groeten", "groet", "mvg", "alvast bedankt", "bedankt", "dank u wel",
82
+ ],
83
+ "honorifics": ["dhr", "mevr", "heer", "mevrouw", "de heer"],
84
+ },
85
+ "it": {
86
+ "verified": False,
87
+ "dateparser": "it",
88
+ "headers": {"from": ["da"], "sent": ["inviato", "data"], "to": ["a"], "cc": ["cc", "ccn"], "subject": ["oggetto"]},
89
+ "separators": ["messaggio originale", "messaggio inoltrato"],
90
+ "wrote": ["ha scritto"],
91
+ "greetings": ["gentile", "gentilissimo", "gentilissima", "egregio", "caro", "cara", "buongiorno", "buonasera", "salve", "ciao"],
92
+ "closings": ["cordiali saluti", "distinti saluti", "un saluto", "saluti", "cordialmente", "grazie mille", "grazie"],
93
+ "honorifics": ["sig", "sig.ra", "dott", "dott.ssa", "signor", "signora"],
94
+ },
95
+ "es": {
96
+ "verified": False,
97
+ "dateparser": "es",
98
+ "headers": {"from": ["de"], "sent": ["enviado", "fecha"], "to": ["para"], "cc": ["cc", "cco"], "subject": ["asunto"]},
99
+ "separators": ["mensaje original", "mensaje reenviado"],
100
+ "wrote": ["escribió"],
101
+ "greetings": ["estimado", "estimada", "estimados", "hola", "buenos días", "buenas tardes", "apreciado", "apreciada"],
102
+ "closings": ["saludos cordiales", "un saludo", "saludos", "atentamente", "cordialmente", "muchas gracias", "gracias"],
103
+ "honorifics": ["sr", "sra", "srta", "don", "doña"],
104
+ },
105
+ "pt": {
106
+ "verified": False,
107
+ "dateparser": "pt",
108
+ "headers": {"from": ["de"], "sent": ["enviado", "enviada", "data"], "to": ["para"], "cc": ["cc", "cco"], "subject": ["assunto"]},
109
+ "separators": ["mensagem original", "mensagem encaminhada"],
110
+ "wrote": ["escreveu"],
111
+ "greetings": ["prezado", "prezada", "caro", "cara", "olá", "bom dia", "boa tarde", "exmo", "exma"],
112
+ "closings": [
113
+ "atenciosamente", "com os melhores cumprimentos", "melhores cumprimentos", "cumprimentos",
114
+ "saudações", "obrigado", "obrigada", "abraço",
115
+ ],
116
+ "honorifics": ["sr", "sra", "dr", "dra"],
117
+ },
118
+ "pl": {
119
+ "verified": False,
120
+ "dateparser": "pl",
121
+ "headers": {"from": ["od"], "sent": ["wysłano", "data"], "to": ["do"], "cc": ["dw", "udw"], "subject": ["temat"]},
122
+ "separators": ["wiadomość oryginalna", "oryginalna wiadomość", "wiadomość przekazana dalej"],
123
+ "wrote": ["napisał(a)", "napisał", "napisała"],
124
+ "greetings": ["szanowny", "szanowna", "szanowni państwo", "dzień dobry", "witam", "cześć"],
125
+ "closings": ["z poważaniem", "z wyrazami szacunku", "serdecznie pozdrawiam", "pozdrawiam", "pozdrowienia", "dziękuję"],
126
+ "honorifics": ["pan", "pani", "panie"],
127
+ },
128
+ "cs": {
129
+ "verified": False,
130
+ "dateparser": "cs",
131
+ "headers": {"from": ["od"], "sent": ["odesláno", "datum"], "to": ["komu"], "cc": ["kopie"], "subject": ["předmět"]},
132
+ "separators": ["původní zpráva", "přeposlaná zpráva"],
133
+ "wrote": ["napsal(a)", "napsal", "napsala"],
134
+ "greetings": ["dobrý den", "vážený", "vážená", "vážení", "ahoj"],
135
+ "closings": ["s přátelským pozdravem", "s pozdravem", "děkuji", "díky"],
136
+ "honorifics": ["pan", "paní", "pane"],
137
+ },
138
+ "sv": {
139
+ "verified": False,
140
+ "dateparser": "sv",
141
+ "headers": {"from": ["från"], "sent": ["skickat", "datum"], "to": ["till"], "cc": ["kopia"], "subject": ["ämne"]},
142
+ "separators": ["ursprungligt meddelande", "vidarebefordrat meddelande"],
143
+ "wrote": ["skrev"],
144
+ "greetings": ["hej", "hejsan", "hallå", "god morgon", "bästa"],
145
+ "closings": ["med vänliga hälsningar", "vänliga hälsningar", "bästa hälsningar", "hälsningar", "mvh", "tack på förhand", "tack"],
146
+ "honorifics": [],
147
+ },
148
+ "da": {
149
+ "verified": False,
150
+ "dateparser": "da",
151
+ "headers": {"from": ["fra"], "sent": ["sendt", "dato"], "to": ["til"], "cc": ["cc"], "subject": ["emne"]},
152
+ "separators": ["oprindelig meddelelse", "videresendt meddelelse"],
153
+ "wrote": ["skrev"],
154
+ "greetings": ["hej", "kære", "goddag", "god morgen"],
155
+ "closings": ["med venlig hilsen", "venlig hilsen", "bedste hilsner", "hilsen", "mvh", "mange tak", "tak"],
156
+ "honorifics": [],
157
+ },
158
+ "no": {
159
+ "verified": False,
160
+ "dateparser": "nb",
161
+ "headers": {"from": ["fra"], "sent": ["sendt", "dato"], "to": ["til"], "cc": ["kopi"], "subject": ["emne"]},
162
+ "separators": ["opprinnelig melding", "videresendt melding"],
163
+ "wrote": ["skrev"],
164
+ "greetings": ["hei", "hallo", "kjære", "god morgen"],
165
+ "closings": ["med vennlig hilsen", "vennlig hilsen", "hilsen", "mvh", "tusen takk", "takk"],
166
+ "honorifics": [],
167
+ },
168
+ "fi": {
169
+ "verified": False,
170
+ "dateparser": "fi",
171
+ "headers": {
172
+ "from": ["lähettäjä"], "sent": ["lähetetty", "päivämäärä"], "to": ["vastaanottaja"], "cc": ["kopio"], "subject": ["aihe"],
173
+ },
174
+ "separators": ["alkuperäinen viesti", "välitetty viesti"],
175
+ "wrote": ["kirjoitti"],
176
+ "greetings": ["hei", "hyvä", "moi", "terve", "hyvää huomenta"],
177
+ "closings": ["ystävällisin terveisin", "parhain terveisin", "terveisin", "kiitos paljon", "kiitos"],
178
+ "honorifics": [],
179
+ },
180
+ }
181
+
182
+
183
+ def all_words(field: str) -> list[str]:
184
+ """Every word of one field across languages, longest first so regex alternation prefers it."""
185
+ words = {w for lang in LANGUAGES.values() for w in lang[field]} # type: ignore[literal-required]
186
+ return sorted(words, key=lambda w: (-len(w), w))
187
+
188
+
189
+ def header_labels() -> dict[str, str]:
190
+ """label (lowercase) -> canonical header name."""
191
+ return {label: key for lang in LANGUAGES.values() for key, labels in lang["headers"].items() for label in labels}
192
+
193
+
194
+ def label_languages() -> dict[str, list[str]]:
195
+ """Sent/date label -> dateparser codes of the languages using it (e.g. 'data' -> it, pt, pl)."""
196
+ out: dict[str, list[str]] = {}
197
+ for lang in LANGUAGES.values():
198
+ for label in lang["headers"]["sent"]:
199
+ out.setdefault(label, [])
200
+ if lang["dateparser"] not in out[label]:
201
+ out[label].append(lang["dateparser"])
202
+ return out
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.5
2
+ Name: email-thread-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for the AR email agent: parses an inbound email thread into the thread contract and validates the agent's output envelope.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: dateparser<2,>=1.2
9
+ Requires-Dist: mcp<3,>=2.3
10
+ Description-Content-Type: text/markdown
11
+
12
+ # email-thread-mcp
13
+
14
+ MCP server (stdio) with two deterministic tools for an accounts-receivable (AR) email agent:
15
+ no LLM inside, no network access, no credentials.
16
+
17
+ | Tool | Role in the agent | Output contract |
18
+ |---|---|---|
19
+ | `parse_email_thread(body, ar_mailboxes?)` | first step: request preprocessing. Splits the raw email body (Outlook/Gmail/Apple HTML or plain text) into the latest message, its signer and greeting, and the quoted history (oldest first) | [`contracts/thread.schema.json`](contracts/thread.schema.json) |
20
+ | `validate_output(output, categories, thread?)` | last step: deterministic checks of the final envelope. Corrects only unambiguous problems (category case/punctuation, duplicates, invoice-number noise, human-review flag); reports the rest | [`contracts/validation.schema.json`](contracts/validation.schema.json) |
21
+
22
+ ## Use
23
+
24
+ Any MCP client that starts stdio servers can run it with `uvx`:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "email-thread-mcp": {
30
+ "command": "uvx",
31
+ "args": ["email-thread-mcp@0.2.0"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Pin a version: a client that keeps the server running may otherwise serve a stale cached build.
38
+
39
+ Each tool returns one JSON object. Structured arguments (`ar_mailboxes`, `categories`, `output`,
40
+ `thread`) may be passed as values or as text: clients that render arguments through a template
41
+ engine first can send JSON or a Python-style repr, and both are accepted.
42
+
43
+ A typical agent calls `parse_email_thread` first, as preprocessing, and uses `latest_message`,
44
+ `reply_to_name` and `thread_context` in later steps; it calls `validate_output` last, on its final
45
+ envelope. If parsing fails, falling back to the plain-text body before the first quote marker keeps
46
+ the agent working.
47
+
48
+ ## Language support
49
+
50
+ Inbound mail may be in any language, so the parser treats its three kinds of miss differently:
51
+
52
+ | Miss | Effect | How it is avoided |
53
+ |---|---|---|
54
+ | quote boundary | old history read as the new message | found by structure in any language: the mail client's HTML quote marker (Outlook `divRplyFwdMsg`, Gmail `gmail_quote`, Apple/Thunderbird `blockquote type="cite"`), or a `label: value` block that starts with an address and holds a date with a time |
55
+ | greeting / signer name | `reply_to_name` is null, the draft greets neutrally | per-language words in `src/email_thread_mcp/vocab.py` |
56
+ | sent date | `sent_at` is null (thread order comes from position, not dates) | [`dateparser`](https://pypi.org/project/dateparser/), absolute dates only |
57
+
58
+ `vocab.py` covers EN, DE, FR, NL, IT, ES, PT, PL, CS, SV, DA, NO, FI. Only EN is `verified` against
59
+ real threads; the others are built from Outlook's localized reply headers. A language outside the
60
+ list still splits, and `parse_info.warnings` names what was not understood, e.g.
61
+ `quoted message 1: header labels not recognised: Kimden, Gönderildi, Kime, Konu`. Search traces
62
+ for those warnings to see which language to add next.
63
+
64
+ To add or verify a language: add its entry to `vocab.py` and a case to `tests/test_languages.py`.
65
+ Once a real thread in it (anonymised as below) parses correctly, add it as a fixture and set `verified`.
66
+
67
+ ## Untrusted input
68
+
69
+ The body is written by the customer, and anyone can send one. The parser:
70
+
71
+ - drops text a reader cannot see (CSS `display:none`, `visibility:hidden`, `font-size:0`, `opacity:0`,
72
+ `mso-hide:all`, the `hidden` attribute) and zero-width, bidirectional and control characters, and
73
+ reports both in `parse_info.warnings`;
74
+ - bounds its own cost: the body is cut at 2M raw / 300k text characters, and dates beyond 50 parser
75
+ calls per email stay null.
76
+
77
+ It does not make the content trustworthy. The quoted history in particular is plain text the customer
78
+ can edit, including a fake message "from" the AR team (role `recipient`). Prompts must treat
79
+ `latest_message` and `thread_context` as data, never as instructions.
80
+
81
+ ## Release
82
+
83
+ 1. Bump `version` in `pyproject.toml`, then `uv run pytest`.
84
+ 2. Commit, tag `v<version>`, push both. Never move or reuse a tag or a version.
85
+ 3. `uv build`, check that the sdist holds only `src/`, `README.md` and `pyproject.toml`, then
86
+ `uv publish`.
87
+ 4. Check: `uvx email-thread-mcp@<version>` starts and waits on stdin.
88
+
89
+ ## Develop
90
+
91
+ ```
92
+ uv sync
93
+ uv run pytest # 14 real Outlook threads, synthetic variants, 13 languages, hostile input
94
+ uv run email-thread-mcp # stdio server
95
+ ```
96
+
97
+ Real customer threads stay out of this repository. Locally, `tests/fixtures/` and
98
+ `tests/test_client_threads.py` (both gitignored) hold anonymised real threads and their expected
99
+ results; the suite runs them when present. Replace every real address and name before using a
100
+ thread there.
@@ -0,0 +1,11 @@
1
+ email_thread_mcp/__init__.py,sha256=VA_x7-P1kBOna1dMzKIvEjtjwmIsfCw-7uiOWi_gCvs,102
2
+ email_thread_mcp/html_text.py,sha256=B76VSdt0fOW5_D7dImXibhSVHWuvIYMHfbTDf-4ytBM,5863
3
+ email_thread_mcp/server.py,sha256=TmS0u6mYxELpngU88jCg7aWjpWbv67UvgskmbvJ4kBk,3180
4
+ email_thread_mcp/thread.py,sha256=nKxJ8BM-Q_h_ML9asDB2x0Oj6qP4d0nRU0zadzuIChY,23458
5
+ email_thread_mcp/validate.py,sha256=8KQxmlf99kBEKbkRvtpjEcXM2GjEMYfgZ3laPsbtJ1U,6698
6
+ email_thread_mcp/vocab.py,sha256=e-seY064BuWrj9_UaU2czklgzoxoGjQDq2Jn0QOqUJg,10228
7
+ email_thread_mcp-0.2.0.dist-info/METADATA,sha256=Azvkc1eoAVlbszpftc2km835KB4KPnB76z60VBrK_3E,5035
8
+ email_thread_mcp-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ email_thread_mcp-0.2.0.dist-info/entry_points.txt,sha256=-wnZhzz3442_FZ632-C0cOkbSGUNSE8BqLbUGIVsp7s,66
10
+ email_thread_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=_tqcjbZk3EIYBYNlVAYkpebamRyLpBhbuKiOvfoEXTk,1064
11
+ email_thread_mcp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ email-thread-mcp = email_thread_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OraCool
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.