File size: 10,056 Bytes
e79f133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
"""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 "")