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.
- email_thread_mcp/__init__.py +1 -0
- email_thread_mcp/html_text.py +141 -0
- email_thread_mcp/server.py +85 -0
- email_thread_mcp/thread.py +565 -0
- email_thread_mcp/validate.py +146 -0
- email_thread_mcp/vocab.py +202 -0
- email_thread_mcp-0.2.0.dist-info/METADATA +100 -0
- email_thread_mcp-0.2.0.dist-info/RECORD +11 -0
- email_thread_mcp-0.2.0.dist-info/WHEEL +4 -0
- email_thread_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- email_thread_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|