bengu-1 / inference /bengu_view.py
uzay-eth's picture
Bengu-1 package r4 (bengu repo 1456a0f)
e79f133 verified
Raw History Blame Contribute Delete
10.1 kB
"""Bengu-1 input view: raw RFC 822 mail bytes -> the exact text the Bengu-1 classifier reads.
Standalone (Python standard library only). The model was trained on this view: a header-facts line,
sender, Reply-To, subject, then the decoded body text (HTML reduced to text), at most about 12,000
characters of a JSON-serialised data block. Mail larger than 256 KiB is read from its headers only.
Mail the user sent or accepted is not marked: no acceptance or provenance facts reach the view (they
are fixed to "unknown" here, exactly as in training). Nothing is fetched; links are never followed.
from bengu_view import view
text = view(open("message.eml", "rb").read())
"""
import json
import re
import unicodedata
from email import policy
from email.parser import BytesParser
from email.utils import getaddresses
from html.parser import HTMLParser
import ipaddress
LIMIT = 8000 # body and header characters kept from the parse
MAX_MESSAGE = 262144 # larger mail: headers only, body marked truncated
DATA_LIMIT = 12000 # characters of the serialised data block
FIELD_LIMIT = 300 # per header-summary string
DOMAIN = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+\Z")
LOCAL = re.compile(r"[a-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}\Z")
FREEMAIL = {"gmail.com", "googlemail.com", "yahoo.com", "hotmail.com", "outlook.com", "live.com", "icloud.com",
"me.com", "mac.com", "aol.com", "yandex.com", "yandex.com.tr", "proton.me", "protonmail.com",
"msn.com", "mail.com", "gmx.com", "hotmail.com.tr", "windowslive.com"}
NEUTRAL_AUTH = {"dkim_aligned_pass": False, "spf": "unknown", "dmarc": "unknown", "gmail_forwarded": False}
class _Unsafe(ValueError):
pass
def clean(text, limit=LIMIT):
text = unicodedata.normalize("NFKC", str(text))
return "".join(c for c in text if c in "\n\t" or not unicodedata.category(c).startswith("C"))[:limit]
def _domain(value):
value = value.lower()
if not value.isascii() or "xn--" in value or len(value) > 253 or not DOMAIN.fullmatch(value):
raise _Unsafe("domain")
try:
ipaddress.ip_address(value)
except ValueError:
return value
raise _Unsafe("domain")
def _address(value):
if not isinstance(value, str) or value.count("@") != 1 or any(c in value for c in "\r\n\x00"):
raise _Unsafe("address")
local, host = value.lower().split("@")
if not LOCAL.fullmatch(local):
raise _Unsafe("address")
return local + "@" + _domain(host)
def _single_address(headers, name):
values = headers.get_all(name, [])
if len(values) != 1 or any(c in str(values[0]) for c in "\r\n\x00"):
return None
parsed = getaddresses([str(values[0])])
if len(parsed) != 1:
return None
try:
return _address(parsed[0][1])
except _Unsafe:
return None
class _TextHTML(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.hidden, self.parts, self.length = 0, [], 0
def handle_starttag(self, tag, attrs):
if tag in {"script", "style", "head", "template"}:
self.hidden += 1
elif tag in {"p", "br", "div", "li", "tr"}:
self.handle_data("\n")
def handle_endtag(self, tag):
if tag in {"script", "style", "head", "template"} and self.hidden:
self.hidden -= 1
def handle_data(self, data):
if not self.hidden and self.length < LIMIT:
chunk = data[:LIMIT - self.length]
self.parts.append(chunk)
self.length += len(chunk)
def _header_bytes(raw):
for sep in (b"\r\n\r\n", b"\n\n"):
cut = raw.find(sep)
if cut >= 0:
return raw[:cut + len(sep)]
return raw
def parse(raw: bytes):
"""(headers, sender, subject, text, bounded)"""
oversized = len(raw) > MAX_MESSAGE
if oversized:
raw = _header_bytes(raw)
if len(raw) > MAX_MESSAGE:
raise ValueError("header section larger than 256 KiB")
msg = BytesParser(policy=policy.default).parsebytes(raw)
chunks, size, complete = [], 0, True
for part in msg.walk():
if part.is_multipart() or part.get_content_disposition() == "attachment" or part.get_filename():
if not part.is_multipart():
complete = False
continue
if part.get_content_type() not in {"text/plain", "text/html"}:
complete = False
continue
payload = part.get_payload(decode=True) or b""
try:
text = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
except LookupError:
text = payload.decode("utf-8", errors="replace")
if part.get_content_type() == "text/html":
parser = _TextHTML()
parser.feed(text)
text = "".join(parser.parts)
if parser.length >= LIMIT:
complete = False
if len(text) > LIMIT - size:
complete = False
chunk = clean(text, LIMIT - size)
chunks.append(chunk)
size += len(chunk)
if size >= LIMIT:
complete = False
break
all_headers = "\n".join(f"{k}: {v}" for k, v in msg.items())
if len(all_headers) > LIMIT or len(msg.get_all("Subject", [])) > 1:
complete = False
return (msg, _single_address(msg, "From"), clean(msg.get("Subject", ""), 500), clean("\n".join(chunks)),
complete and not oversized and not msg.defects)
def _one_line(value, limit=FIELD_LIMIT):
return " ".join(clean(value, limit * 2).split())[:limit]
def _single(headers, name):
values = headers.get_all(name, [])
return str(values[0]) if len(values) == 1 else ""
def _display_name(headers):
values = headers.get_all("From", [])
if len(values) != 1:
return ""
parsed = getaddresses([str(values[0])])
return parsed[0][0] if len(parsed) == 1 else ""
def _addresses(headers, name, limit=10):
found = []
for value in headers.get_all(name, [])[:2]:
for _, item in getaddresses([str(value)]):
try:
found.append(_address(item))
except _Unsafe:
continue
return found[:limit]
def _attachments(headers):
return sum(1 for part in headers.walk()
if not part.is_multipart() and (part.get_content_disposition() == "attachment" or part.get_filename()))
def data_block(raw: bytes) -> str:
"""The serialised data block (header summary + body), at most DATA_LIMIT characters."""
headers, sender, subject, body, bounded = parse(raw)
name = _display_name(headers)
sender = sender or ""
summary = {
"from": _one_line((name + " <" + sender + ">") if name else sender or _single(headers, "From")),
"reply_to": _one_line(", ".join(_addresses(headers, "Reply-To"))),
"to": _one_line(", ".join(_addresses(headers, "To"))),
"cc": _one_line(", ".join(_addresses(headers, "Cc"))),
"subject": _one_line(subject),
"date": _one_line(_single(headers, "Date"), 80),
"list_unsubscribe_present": bool(headers.get_all("List-Unsubscribe", [])),
"list_id": _one_line(_single(headers, "List-ID"), 200),
"precedence": _one_line(_single(headers, "Precedence"), 40),
"in_reply_to_present": bool(headers.get_all("In-Reply-To", []) or headers.get_all("References", [])),
"authentication_verified_by_icloud": dict(NEUTRAL_AUTH),
"attachments": _attachments(headers),
}
def serialize(text, truncated):
value = json.dumps({"headers": summary, "body": text, "body_truncated": truncated},
ensure_ascii=False, separators=(",", ":"), sort_keys=False)
return value.replace("<", "\\u003c").replace(">", "\\u003e")
data = serialize(body, not bounded)
if len(data) > DATA_LIMIT:
low, high = 0, len(body)
while low < high:
middle = (low + high + 1) // 2
if len(serialize(body[:middle], True)) <= DATA_LIMIT:
low = middle
else:
high = middle - 1
data = serialize(body[:low], True)
if len(data) > DATA_LIMIT or "<<<" in data or ">>>" in data:
raise ValueError("the data block does not fit")
return data, sender
def _precedence(value):
value = (value or "").strip().lower()
return value if value in ("bulk", "list", "junk") else ("other" if value else "none")
def _sender_kind(sender_domain, h):
if h.get("list_unsubscribe_present") or h.get("list_id") or (h.get("precedence") or "").lower() in {"bulk", "list", "junk"}:
return "bulk_list"
if sender_domain in FREEMAIL:
return "freemail"
return "organisation" if sender_domain else "unknown"
def view_from_data(data: str, sender_domain: str) -> str:
value = json.loads(data)
h = value["headers"]
subject, body = h.get("subject") or "", value.get("body") or ""
sender, reply_to = h.get("from") or "", h.get("reply_to") or ""
recipients = len([x for x in (h.get("to") or "").split(",") if x.strip()]) + \
len([x for x in (h.get("cc") or "").split(",") if x.strip()])
facts = (f"sender: {_sender_kind(sender_domain or '', h)}; list-unsubscribe: {'yes' if h.get('list_unsubscribe_present') else 'no'}; "
f"list-id: {'yes' if h.get('list_id') else 'no'}; precedence: {_precedence(h.get('precedence'))}; "
f"reply-chain: {'yes' if h.get('in_reply_to_present') else 'no'}; attachments: {int(h.get('attachments') or 0)}; "
f"recipients: {recipients}; truncated: {'yes' if value.get('body_truncated') else 'no'}")
return facts + "\nfrom: " + sender + "\nreply-to: " + reply_to + "\nsubject: " + subject + "\n\n" + body
def view(raw: bytes) -> str:
"""Raw mail bytes -> the Bengu-1 input text."""
data, sender = data_block(raw)
return view_from_data(data, sender.split("@")[1] if "@" in sender else "")