eml-parser 4.1.0__tar.gz → 4.2.0__tar.gz
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.
- {eml_parser-4.1.0 → eml_parser-4.2.0}/PKG-INFO +1 -1
- {eml_parser-4.1.0 → eml_parser-4.2.0}/eml_parser/decode.py +1 -1
- {eml_parser-4.1.0 → eml_parser-4.2.0}/eml_parser/regexes.py +0 -3
- eml_parser-4.2.0/eml_parser/routing.py +216 -0
- eml_parser-4.2.0/tests/test_date_offset.py +23 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_routing.py +4 -1
- eml_parser-4.2.0/tests/test_routing_structure.py +219 -0
- eml_parser-4.1.0/eml_parser/routing.py +0 -153
- {eml_parser-4.1.0 → eml_parser-4.2.0}/.gitignore +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/LICENSE +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/README.md +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/eml_parser/__init__.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/eml_parser/anomalies.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/eml_parser/constants.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/eml_parser/parser.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/eml_parser/py.typed +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/examples/recursively_extract_attachments.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/examples/simple_test.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/pyproject.toml +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/__init__.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_attachment_anomalies.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_attachment_counter.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_authentication_results.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_body_charset.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_broken_multipart.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_date_defaults.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_decode.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_decode_robustness.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_defects.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_emlparser.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_hashed_noscheme.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_header_order.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_header_size.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_host_cache.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_html_attribute_urls.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_ignore_bad_start.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_ioc_boundaries.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_mimebomb.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_obfuscated_ip.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_pathological_headers.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_regexes.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_remote_shares.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_sliding_window.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_text_attachments.py +0 -0
- {eml_parser-4.1.0 → eml_parser-4.2.0}/tests/test_xmlns_declarations.py +0 -0
|
@@ -278,7 +278,7 @@ def robust_string2date(line: str) -> datetime.datetime:
|
|
|
278
278
|
# Now we are facing an invalid date.
|
|
279
279
|
return datetime.datetime.fromisoformat(default_date)
|
|
280
280
|
|
|
281
|
-
if date_.
|
|
281
|
+
if date_.utcoffset() is None:
|
|
282
282
|
return date_.replace(tzinfo=datetime.UTC)
|
|
283
283
|
|
|
284
284
|
return date_
|
|
@@ -162,9 +162,6 @@ url_drop_chars_table = str.maketrans('', '', '\t\n\r')
|
|
|
162
162
|
# A (quoted) value starting with a scheme followed by slashes or backslashes, i.e. http:\\\\host\\path
|
|
163
163
|
url_backslash_scheme_regex = re.compile(r"""[\"\']?[a-z][a-z0-9+.\-]*:[\\/]{2}""", re.IGNORECASE)
|
|
164
164
|
|
|
165
|
-
date_regex = re.compile(r""";[ \w\s:,+\-()]+$""")
|
|
166
|
-
cleanline_regex = re.compile(r"""(^[;\s]{0,}|[;\s]{0,}$)""")
|
|
167
|
-
|
|
168
165
|
escape_special_regex_chars = re.compile(r"""([\^$\[\]()+?.])""")
|
|
169
166
|
|
|
170
167
|
window_slice_regex = re.compile(r"""\s""")
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""This module is used for parsing the received lines into a machine readable structure."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import regex
|
|
7
|
+
|
|
8
|
+
import eml_parser.decode
|
|
9
|
+
import eml_parser.regexes
|
|
10
|
+
|
|
11
|
+
# What cleanline() removes: the semicolon and everything the regular expression class \s matches (all Unicode white space).
|
|
12
|
+
_CLEANLINE_CHARS = ';' + ''.join(char for char in map(chr, range(0x3001)) if char.isspace())
|
|
13
|
+
|
|
14
|
+
_DELIMITERS = str.maketrans('()[],;<>"', ' ')
|
|
15
|
+
|
|
16
|
+
# The domain of an address in angle brackets (the sender in "(envelope-from <a@b>)", a message id) is no relay.
|
|
17
|
+
_ANGLE_ADDRESS = regex.compile(r"""<[^<>\s]*@[^<>\s]*>""")
|
|
18
|
+
|
|
19
|
+
# What a date can consist of: words, numbers, and the separators of its time and zone. Anything else is not a date, e.g. "-04= 00".
|
|
20
|
+
_DATE = regex.compile(r"""[\w\s:,+.\-]+""")
|
|
21
|
+
|
|
22
|
+
# A host name in a received line: labels of letters, digits, hyphens and underscores (Exchange and Lotus servers use them: `mail_gw1.example.com`),
|
|
23
|
+
# any label may be a single character (`mail10.m.example.net`). The last label (TLD) consists of letters only, or is punycode (`xn--p1ai`): ids
|
|
24
|
+
# (`1.b9.b1afe75`), versions (`v82.22`, `8.12.0.beta16`) and the like are no hosts. A name followed by `/<digit>` is a mailer version
|
|
25
|
+
# (`8.12.0.beta16/8.12.0`). The labels are matched possessively (a label only if another one follows, labels do not start with a hyphen): the last label can only be the TLD, and
|
|
26
|
+
# backtracking would make the scan quadratic on long runs of `a.a.a.`. An optional `*.` is dropped, the domain of user@host is a host, user.name@host is not.
|
|
27
|
+
_HOST = regex.compile(r"""(?<![\w.\-/])(?:\*\.)?((?:\w[\w\-]*\.(?=\w))++(?:[^\W\d_]{2,}|xn--[\w\-]+))(?![\w\-@]|\.\w|/\d)""")
|
|
28
|
+
|
|
29
|
+
# The tokens which matter for the structure of a received line. Parentheses delimit comments, which are skipped when looking for the keywords.
|
|
30
|
+
# A keyword is a whole word, i.e. it is not part of a host name or an address (toby, before, nearby, ...).
|
|
31
|
+
_STRUCTURE = regex.compile(r"""[()]|;|(?<![^\s);\[])(?:from|by|with|for)(?=[\s(]|$)|(?<=\s)received:(?=\s)""")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cleanline(line: str) -> str:
|
|
35
|
+
"""Remove space and ; from start/end of line.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
line (str): Line to clean.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
str: Cleaned string.
|
|
42
|
+
"""
|
|
43
|
+
return line.strip(_CLEANLINE_CHARS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_domain_ip(line: str) -> list[str]:
|
|
47
|
+
"""Method returns all domains, IPv4 and IPv6 addresses found in a given string.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
line (str): String to search in.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
list: Unique list of strings with matches, in order of first appearance (domains, then IPv4, then IPv6 addresses).
|
|
54
|
+
"""
|
|
55
|
+
found: list[str] = []
|
|
56
|
+
|
|
57
|
+
# Brackets and parentheses delimit a host as well as white space does: "by notes.example.com(Lotus SMTP MTA)", "by [mail.example.com]"
|
|
58
|
+
line = line.translate(_DELIMITERS)
|
|
59
|
+
|
|
60
|
+
if '.' in line:
|
|
61
|
+
found += _HOST.findall(line)
|
|
62
|
+
found += eml_parser.regexes.ipv4_regex.findall(line)
|
|
63
|
+
|
|
64
|
+
if ':' in line:
|
|
65
|
+
found += eml_parser.regexes.ipv6_regex.findall(line)
|
|
66
|
+
|
|
67
|
+
return list(dict.fromkeys(found))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _remove_comments(line: str, comments: list[tuple[int, int]], start: int) -> str:
|
|
71
|
+
"""Cut the comments out of the end of a line and normalise the white space.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
line: The line.
|
|
75
|
+
comments: Start and end of the comments in the line (nested ones too, as an enclosing parenthesis might never be closed).
|
|
76
|
+
start: Position in the line to start at.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
str: The text from start to the end of the line, without comments.
|
|
80
|
+
"""
|
|
81
|
+
parts = []
|
|
82
|
+
for comment_start, comment_end in sorted(comments):
|
|
83
|
+
if comment_end > start:
|
|
84
|
+
parts.append(line[start : max(start, comment_start)])
|
|
85
|
+
start = max(start, comment_end)
|
|
86
|
+
|
|
87
|
+
parts.append(line[start:])
|
|
88
|
+
|
|
89
|
+
return ' '.join(' '.join(parts).split())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_date(line: str, comments: list[tuple[int, int]], separators: list[int]) -> dt.datetime | None:
|
|
93
|
+
"""Parse the date behind the last semicolon of a received line.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
line: The line.
|
|
97
|
+
comments: Start and end of the comments in the line.
|
|
98
|
+
separators: Positions of the semicolons outside of comments.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The date, or None if there is none. An unparsable date is not reported (robust_string2date() would make up a default date), nor is
|
|
102
|
+
one which is not made of date characters, as dateutil ignores whatever it does not know and returns a wrong time.
|
|
103
|
+
"""
|
|
104
|
+
if not separators:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# A line wrapped in brackets ends with one
|
|
108
|
+
date = _remove_comments(line, comments, separators[-1] + 1).removesuffix(']').rstrip()
|
|
109
|
+
if not _DATE.fullmatch(date) or not any(char.isdigit() for char in date):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
parsed_date = eml_parser.decode.robust_string2date(date)
|
|
113
|
+
return None if parsed_date.isoformat() == eml_parser.decode.default_date else parsed_date
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parserouting(line: str, email_regex: regex.Pattern[str] = eml_parser.regexes.email_no_force_tld_regex) -> dict[str, typing.Any]:
|
|
117
|
+
"""This method tries to parse an e-mail header received line and extract machine-readable information.
|
|
118
|
+
|
|
119
|
+
Note that there are a large number of formats for these lines
|
|
120
|
+
and a lot of weird ones which are not commonly used.
|
|
121
|
+
We try our best to match a large number of formats.
|
|
122
|
+
|
|
123
|
+
The line is split in one pass. The first *from*, *by*, *with* and *for* outside of comments (parentheses) start a section, which ends where
|
|
124
|
+
the next one starts. The last section ends at the semicolon in front of the date. The text of a section, comments included, is the value
|
|
125
|
+
of *with*, and is searched for hosts and addresses for *from*, *by* and *for*.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
line (str): Received line to be parsed.
|
|
129
|
+
email_regex (regex.Pattern[str], optional): Pattern used to extract e-mail addresses from the 'for' field.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
dict: Returns a dict with the extracted information.
|
|
133
|
+
"""
|
|
134
|
+
out: dict[str, typing.Any] = {'src': line}
|
|
135
|
+
line = line.lower()
|
|
136
|
+
|
|
137
|
+
# Collect the structural tokens outside of comments. A closing parenthesis removes everything since its opening one; an unbalanced
|
|
138
|
+
# opening parenthesis does not start a comment.
|
|
139
|
+
open_comments: list[tuple[int, int]] = []
|
|
140
|
+
comments: list[tuple[int, int]] = []
|
|
141
|
+
tokens: list[tuple[str, int, int]] = []
|
|
142
|
+
for match in _STRUCTURE.finditer(line):
|
|
143
|
+
kind = match.group()
|
|
144
|
+
|
|
145
|
+
if kind == '(':
|
|
146
|
+
open_comments.append((len(tokens), match.start()))
|
|
147
|
+
elif kind == ')':
|
|
148
|
+
if open_comments:
|
|
149
|
+
mark, start = open_comments.pop()
|
|
150
|
+
del tokens[mark:]
|
|
151
|
+
comments.append((start, match.end()))
|
|
152
|
+
else:
|
|
153
|
+
tokens.append((kind, match.start(), match.end()))
|
|
154
|
+
|
|
155
|
+
# The first occurrence of each keyword starts a section, later ones are part of the text. The last semicolon is the one in front of the date.
|
|
156
|
+
sections: dict[str, tuple[int, int]] = {}
|
|
157
|
+
separators: list[int] = []
|
|
158
|
+
for kind, start, end in tokens:
|
|
159
|
+
if kind == ';':
|
|
160
|
+
separators.append(start)
|
|
161
|
+
elif kind == 'received:':
|
|
162
|
+
# Detect "sticked lines"
|
|
163
|
+
out['warning'] = ['Merged Received headers']
|
|
164
|
+
return out
|
|
165
|
+
elif kind not in sections:
|
|
166
|
+
sections[kind] = (start, end)
|
|
167
|
+
|
|
168
|
+
if not sections:
|
|
169
|
+
# Lines such as "(qmail 4711 invoked by uid 500); 28 Sep 2002 16:21:17 -0000" have a date but no relay.
|
|
170
|
+
out['warning'] = ['Nothing Parsable']
|
|
171
|
+
date = _parse_date(line, comments, separators)
|
|
172
|
+
if date is not None:
|
|
173
|
+
out['date'] = date
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
ordered = sorted((start, end, keyword) for keyword, (start, end) in sections.items())
|
|
177
|
+
for position, (_, end, keyword) in enumerate(ordered):
|
|
178
|
+
if position + 1 < len(ordered):
|
|
179
|
+
stop = ordered[position + 1][0]
|
|
180
|
+
else:
|
|
181
|
+
# The last section ends at the first semicolon after it.
|
|
182
|
+
stop = next((start for start in separators if start > end), len(line))
|
|
183
|
+
|
|
184
|
+
value = cleanline(line[end:stop])
|
|
185
|
+
if value:
|
|
186
|
+
out[keyword] = value
|
|
187
|
+
|
|
188
|
+
date = _parse_date(line, comments, separators)
|
|
189
|
+
if date is not None:
|
|
190
|
+
out['date'] = date
|
|
191
|
+
|
|
192
|
+
# Fixup for "From" in "for" field
|
|
193
|
+
# ie google, do that...
|
|
194
|
+
if out.get('for'):
|
|
195
|
+
# include spaces in test, otherwise there will be an exception with domains containing "from" in itself
|
|
196
|
+
if ' from ' in out['for']:
|
|
197
|
+
before, _, after = out['for'].partition(' from ')
|
|
198
|
+
out['for'] = before
|
|
199
|
+
out['from'] = f'{out.get("from", "")} {after}'
|
|
200
|
+
|
|
201
|
+
found = email_regex.findall(out['for'])
|
|
202
|
+
if found:
|
|
203
|
+
out['for'] = list(dict.fromkeys(found))
|
|
204
|
+
else:
|
|
205
|
+
del out['for']
|
|
206
|
+
|
|
207
|
+
# Now.. find IP and Host in from and by. The domain of an address in angle brackets, e.g. the one of the sender in
|
|
208
|
+
# "(envelope-from <a@sender.example>)" or of a message id, is no relay.
|
|
209
|
+
for keyword in ('from', 'by'):
|
|
210
|
+
if out.get(keyword):
|
|
211
|
+
text = _ANGLE_ADDRESS.sub(' ', out[keyword]) if '<' in out[keyword] else out[keyword]
|
|
212
|
+
out[keyword] = get_domain_ip(text)
|
|
213
|
+
if not out[keyword]: # if array is empty remove
|
|
214
|
+
del out[keyword]
|
|
215
|
+
|
|
216
|
+
return out
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import eml_parser.decode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
('line', 'expected'),
|
|
10
|
+
[
|
|
11
|
+
# Fractional seconds are not understood by the email module, dateutil parses these and returns an offset without a name.
|
|
12
|
+
('Mon, 5 Oct 2026 09:12:44.123 +0200', '2026-10-05T09:12:44.123000+02:00'),
|
|
13
|
+
('Mon, 5 Oct 2026 09:12:44.123 -0500', '2026-10-05T09:12:44.123000-05:00'),
|
|
14
|
+
('Mon, 5 Oct 2026 09:12:44.123 +0000', '2026-10-05T09:12:44.123000+00:00'),
|
|
15
|
+
('Fri, 29 Jan 2021 07:24:22.501 +0530', '2021-01-29T07:24:22.501000+05:30'),
|
|
16
|
+
],
|
|
17
|
+
)
|
|
18
|
+
def test_offset_of_dateutil_dates_is_kept(line: str, expected: str) -> None:
|
|
19
|
+
assert eml_parser.decode.robust_string2date(line).isoformat() == expected
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_date_without_offset_is_utc() -> None:
|
|
23
|
+
assert eml_parser.decode.robust_string2date('Mon, 5 Oct 2026 09:12:44.123').utcoffset() == datetime.timedelta(0)
|
|
@@ -45,6 +45,7 @@ class TestRouting:
|
|
|
45
45
|
# Tests a received entry which has *from* as part of a field.
|
|
46
46
|
"""Received: by f321.i.example.com with local (envelope-from <b8u3hkqlkj@example.com>) id 1khYpb-0001XE-KE for someone@here-from-there.com; Tue, 24 Nov 2020 16:58:07 +0300""",
|
|
47
47
|
{
|
|
48
|
+
'by': ['f321.i.example.com'],
|
|
48
49
|
'for': ['someone@here-from-there.com'],
|
|
49
50
|
'src': 'Received: by f321.i.example.com with local (envelope-from <b8u3hkqlkj@example.com>) id 1khYpb-0001XE-KE for someone@here-from-there.com; Tue, 24 Nov 2020 16:58:07 +0300',
|
|
50
51
|
'with': 'local (envelope-from <b8u3hkqlkj@example.com>) id 1khypb-0001xe-ke',
|
|
@@ -55,7 +56,9 @@ class TestRouting:
|
|
|
55
56
|
# C.f. github issue #54; unsupported line; point of this test is to make sure we properly catch related exceptions
|
|
56
57
|
r"""Received: from)by ismtpd0112p1sjc2.abc.net (SG) with ESMTP id\n B4glDdmiQcqJYrMuLIqjUQfor <p-j.a@sdf.com>; Fri, 29 Jan 2021\n 07:24:22.501 +0000 (UTC)""",
|
|
57
58
|
{
|
|
58
|
-
'src': r'Received: from)by ismtpd0112p1sjc2.abc.net (SG) with ESMTP id\n B4glDdmiQcqJYrMuLIqjUQfor <p-j.a@sdf.com>; Fri, 29 Jan 2021\n 07:24:22.501 +0000 (UTC)'
|
|
59
|
+
'src': r'Received: from)by ismtpd0112p1sjc2.abc.net (SG) with ESMTP id\n B4glDdmiQcqJYrMuLIqjUQfor <p-j.a@sdf.com>; Fri, 29 Jan 2021\n 07:24:22.501 +0000 (UTC)',
|
|
60
|
+
'by': ['ismtpd0112p1sjc2.abc.net'],
|
|
61
|
+
'with': r'esmtp id\n b4glddmiqcqjyrmuliqjuqfor <p-j.a@sdf.com>',
|
|
59
62
|
},
|
|
60
63
|
),
|
|
61
64
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import eml_parser.routing
|
|
4
|
+
|
|
5
|
+
TAIL = '; Mon, 5 Oct 2026 09:12:44 +0200'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse(line: str) -> dict:
|
|
9
|
+
return eml_parser.routing.parserouting(line)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize(
|
|
13
|
+
('line', 'expected_from', 'expected_by'),
|
|
14
|
+
[
|
|
15
|
+
# a host name ending in a keyword must not start a section
|
|
16
|
+
('from toby (toby.example.com [192.0.2.1]) by mx.example.org with ESMTP' + TAIL, ['toby.example.com', '192.0.2.1'], ['mx.example.org']),
|
|
17
|
+
('from before.example.org (x [192.0.2.7]) by mx.example.org' + TAIL, ['before.example.org', '192.0.2.7'], ['mx.example.org']),
|
|
18
|
+
('from ruby.example.org ([192.0.2.8]) by nearby.example.org with ESMTP' + TAIL, ['ruby.example.org', '192.0.2.8'], ['nearby.example.org']),
|
|
19
|
+
# keywords in comments are not keywords
|
|
20
|
+
('from a.example.com (a.example.com [192.0.2.1]) (using TLS by default) by b.example.com' + TAIL, ['a.example.com', '192.0.2.1'], ['b.example.com']),
|
|
21
|
+
('from a.example.com ([192.0.2.1]) by b.example.com (Postfix, from userid 1000) with ESMTP' + TAIL, ['a.example.com', '192.0.2.1'], ['b.example.com']),
|
|
22
|
+
# an unbalanced opening parenthesis does not swallow the rest
|
|
23
|
+
('from a.example.com (a.example.com [192.0.2.1] by b.example.com with ESMTP' + TAIL, ['a.example.com', '192.0.2.1'], ['b.example.com']),
|
|
24
|
+
# upper case
|
|
25
|
+
('FROM A.EXAMPLE.COM (A.EXAMPLE.COM [192.0.2.1]) BY B.EXAMPLE.COM WITH ESMTP' + TAIL, ['a.example.com', '192.0.2.1'], ['b.example.com']),
|
|
26
|
+
],
|
|
27
|
+
)
|
|
28
|
+
def test_sections(line: str, expected_from: list[str], expected_by: list[str]) -> None:
|
|
29
|
+
result = parse(line)
|
|
30
|
+
|
|
31
|
+
assert result['from'] == expected_from
|
|
32
|
+
assert result['by'] == expected_by
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_hosts_are_in_order_of_appearance() -> None:
|
|
36
|
+
result = parse('from mail.example.com (mail.example.com [192.0.2.1] [2001:db8::1]) by mx.example.org' + TAIL)
|
|
37
|
+
|
|
38
|
+
assert result['from'] == ['mail.example.com', '192.0.2.1', '2001:db8::1']
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize(
|
|
42
|
+
('line', 'expected_by'),
|
|
43
|
+
[
|
|
44
|
+
# version numbers are no host names
|
|
45
|
+
('from a.example.com by mx.example.org (Exim 4.96) id 1' + TAIL, ['mx.example.org']),
|
|
46
|
+
('from a.example.com by mx.example.org (8.12.2/8.12.2/20020902/$Revision: 1.30 $) with SMTP' + TAIL, ['mx.example.org']),
|
|
47
|
+
# the domain of an address in angle brackets is no relay
|
|
48
|
+
('from a.example.com by mx.example.org (envelope-from <s@sender.example>) id 1' + TAIL, ['mx.example.org']),
|
|
49
|
+
('from a.example.com by mx.example.org with SMTP id <B001@msg.example>' + TAIL, ['mx.example.org']),
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
def test_by_contains_only_relays(line: str, expected_by: list[str]) -> None:
|
|
53
|
+
assert parse(line)['by'] == expected_by
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_ident_host_of_sendmail_is_kept() -> None:
|
|
57
|
+
result = parse('from client (user@c-24-98-25-152.atl.example.com [24.98.25.152]) by mx.example.org with SMTP' + TAIL)
|
|
58
|
+
|
|
59
|
+
assert result['from'] == ['c-24-98-25-152.atl.example.com', '24.98.25.152']
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_empty_sections_are_omitted() -> None:
|
|
63
|
+
result = parse('from by mx.example.org with ;')
|
|
64
|
+
|
|
65
|
+
assert 'from' not in result
|
|
66
|
+
assert result['by'] == ['mx.example.org']
|
|
67
|
+
assert 'with' not in result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_repeated_keyword_belongs_to_the_previous_section() -> None:
|
|
71
|
+
result = parse('from a.example.com by b.example.com for <x@example.com> from c.example.com' + TAIL)
|
|
72
|
+
|
|
73
|
+
assert result['for'] == ['x@example.com']
|
|
74
|
+
assert result['from'] == ['a.example.com', 'c.example.com']
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_for_addresses_are_in_order_of_appearance() -> None:
|
|
78
|
+
result = parse('from a.example.com by b.example.com for <z@example.com> <a@example.com>' + TAIL)
|
|
79
|
+
|
|
80
|
+
assert result['for'] == ['z@example.com', 'a@example.com']
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize(
|
|
84
|
+
('line', 'expected'),
|
|
85
|
+
[
|
|
86
|
+
('from a.example.com by b.example.com' + TAIL + ' (CEST)', '2026-10-05T09:12:44+02:00'),
|
|
87
|
+
('from a.example.com by b.example.com; Mon, 5 Oct 2026 09:12:44.123 -0500', '2026-10-05T09:12:44.123000-05:00'),
|
|
88
|
+
('from a.example.com by b.example.com; 5 Oct 2026 07:12:44 -0000', '2026-10-05T07:12:44+00:00'),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
def test_date(line: str, expected: str) -> None:
|
|
92
|
+
assert parse(line)['date'].isoformat() == expected
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.parametrize(
|
|
96
|
+
'line', ['from a.example.com by b.example.com', 'from a.example.com by b.example.com; not a date', 'from a.example.com by b.example.com;']
|
|
97
|
+
)
|
|
98
|
+
def test_no_date_is_invented(line: str) -> None:
|
|
99
|
+
assert 'date' not in parse(line)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_merged_and_unparsable_lines() -> None:
|
|
103
|
+
assert parse('from a.example.com by b.example.com ; mon, 5 oct 2026 09:12:44 +0200 received: from c.example.com by d.example.com')['warning'] == [
|
|
104
|
+
'Merged Received headers'
|
|
105
|
+
]
|
|
106
|
+
assert parse('(qmail 12345 invoked by uid 0); 22 Aug 2002 08:28:38 -0000')['warning'] == ['Nothing Parsable']
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize('filler', [';', '(', ')', ' ', '(x) ', 'by ', 'a.'])
|
|
110
|
+
def test_parsing_time_is_linear(filler: str) -> None:
|
|
111
|
+
"""Runs of semicolons and spaces took seconds (quadratic) in the old implementation, the others are included for completeness."""
|
|
112
|
+
result = parse('from a.example.com ' + filler * 50_000 + 'x by b.example.com' + TAIL)
|
|
113
|
+
|
|
114
|
+
assert result['by'] == ['b.example.com'] or 'by' not in result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize(
|
|
118
|
+
('text', 'expected'),
|
|
119
|
+
[('', ''), (' ; x ; ', 'x'), ('; ; x ;', 'x'), ('x', 'x')],
|
|
120
|
+
)
|
|
121
|
+
def test_cleanline_unicode_whitespace(text: str, expected: str) -> None:
|
|
122
|
+
assert eml_parser.routing.cleanline(text) == expected
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize(
|
|
126
|
+
('line', 'expected_by'),
|
|
127
|
+
[
|
|
128
|
+
# a comment directly behind the host name
|
|
129
|
+
(
|
|
130
|
+
'from a.example.com by notes.example.com(Lotus SMTP MTA v4.6.5 (863.2 5-20-1999)) id 86256895.00832fb3 ; Wed, 1 Mar 2000 17:52:54 -0600',
|
|
131
|
+
['notes.example.com'],
|
|
132
|
+
),
|
|
133
|
+
# a host in brackets
|
|
134
|
+
(
|
|
135
|
+
'from elpmime02.example.com ([192.0.2.90]) by [mail.example.com] (GroupWise SMTP/MIME daemon 4.1 v3) ; Mon, 19 Jun 2000 17:21:00 -0700',
|
|
136
|
+
['mail.example.com'],
|
|
137
|
+
),
|
|
138
|
+
# ids and versions are no hosts: mailer message ids and versions
|
|
139
|
+
(
|
|
140
|
+
'from a.example.com by imo-m10.mx.example.net (mail_out_v31_r1.9.) id 2.73.172b0394 (2519) for <ken@example.com>; Tue, 4 Dec 2001 13:02:04 -0500',
|
|
141
|
+
['imo-m10.mx.example.net'],
|
|
142
|
+
),
|
|
143
|
+
('from a.example.com by mx.example.com (v82.22) with ESMTP id MAILINYE410-1115115900; Thu, 15 Nov 2001 11:59:00 -0500', ['mx.example.com']),
|
|
144
|
+
(
|
|
145
|
+
'from a.example.com by mx.example.com (8.10.1/8.10.1/external_corp-1.08) with ESMTP id g0mgoa021678; Tue, 22 Jan 2002 10:24:10 -0600',
|
|
146
|
+
['mx.example.com'],
|
|
147
|
+
),
|
|
148
|
+
],
|
|
149
|
+
)
|
|
150
|
+
def test_by_in_real_world_formats(line: str, expected_by: list[str]) -> None:
|
|
151
|
+
assert parse(line)['by'] == expected_by
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_line_in_brackets() -> None:
|
|
155
|
+
result = parse(
|
|
156
|
+
'[from pobox3.example.org (pobox3.example.org [192.0.2.242]) by motgate4.example.org (motgate4 2.1) with ESMTP id haa08361; Tue, 13 Nov 2001 07:51:10 -0700 (MST)]'
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert result['from'] == ['pobox3.example.org', '192.0.2.242']
|
|
160
|
+
assert result['by'] == ['motgate4.example.org']
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize(
|
|
164
|
+
'tail',
|
|
165
|
+
[
|
|
166
|
+
'; tue, 15 aug 2000 gmt', # no time
|
|
167
|
+
'; wed, 21 feb 2001 19:29: 59 -0500', # blank in the time
|
|
168
|
+
'; tue, 15 aug 2000 08:57:09 -04= 00', # left over of a quoted-printable line break, dateutil would ignore it and return a wrong zone
|
|
169
|
+
'; of, 15 ssa 2000 09:40:38 -0600',
|
|
170
|
+
],
|
|
171
|
+
)
|
|
172
|
+
def test_garbage_is_not_a_date(tail: str) -> None:
|
|
173
|
+
assert 'date' not in parse('from a.example.com by b.example.com' + tail)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.parametrize(
|
|
177
|
+
('text', 'expected'),
|
|
178
|
+
[
|
|
179
|
+
('mail10.m.example.net', ['mail10.m.example.net']), # single character labels
|
|
180
|
+
('mail_gw1.example.com', ['mail_gw1.example.com']), # underscore, used by Exchange and Lotus servers
|
|
181
|
+
('a.example.com, b.example.org;', ['a.example.com', 'b.example.org']), # separated by comma
|
|
182
|
+
('*.example.com', ['example.com']), # wildcard
|
|
183
|
+
('user.name@mx.example.com', ['mx.example.com']), # local part of an address
|
|
184
|
+
('id 1.b9.b1afe75 id x.bd.69a6d55', []), # message ids
|
|
185
|
+
('(8.12.0.beta16/8.12.0 v82.22 Exim 4.96)', []), # versions
|
|
186
|
+
('xn--mx-example.xn--p1ai', ['xn--mx-example.xn--p1ai']), # punycode TLD
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
def test_get_domain_ip_host_names(text: str, expected: list[str]) -> None:
|
|
190
|
+
assert eml_parser.routing.get_domain_ip(text) == expected
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_unclosed_parenthesis_does_not_hide_the_date() -> None:
|
|
194
|
+
result = parse(
|
|
195
|
+
'from a.example.com by b.example.org (8.12.2/BASENAME(x.example, .mc):RCS(rev: 1.23 ) with ESMTP id g99GvDmX; Wed, 9 Oct 2002 12:57:14 -0400 (EDT)'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert result['by'] == ['b.example.org', 'x.example']
|
|
199
|
+
assert result['date'].isoformat() == '2002-10-09T12:57:14-04:00'
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_date_of_a_line_without_relay() -> None:
|
|
203
|
+
result = parse('(qmail 18301 invoked by uid 500); 28 Sep 2002 16:21:17 -0000')
|
|
204
|
+
|
|
205
|
+
assert result['warning'] == ['Nothing Parsable']
|
|
206
|
+
assert result['date'].isoformat() == '2002-09-28T16:21:17+00:00'
|
|
207
|
+
assert 'by' not in result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_date_of_a_line_in_brackets() -> None:
|
|
211
|
+
result = parse('[from a.example.com by b.example.org with ESMTP; Wed, 28 Aug 2002 01:29:22 -0700 ]')
|
|
212
|
+
|
|
213
|
+
assert result['date'].isoformat() == '2002-08-28T01:29:22-07:00'
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_host_scan_is_linear() -> None:
|
|
217
|
+
"""The scan for host names backtracked over every label of a long run of labels (400 000 characters took 17 s)."""
|
|
218
|
+
assert eml_parser.routing.get_domain_ip('a.' * 200_000 + 'x') == []
|
|
219
|
+
assert eml_parser.routing.get_domain_ip('a.' * 200_000 + 'example.com') == ['a.' * 200_000 + 'example.com']
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
"""This module is used for parsing the received lines into a machine readable structure."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
import typing
|
|
5
|
-
|
|
6
|
-
import regex
|
|
7
|
-
|
|
8
|
-
import eml_parser.decode
|
|
9
|
-
import eml_parser.regexes
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def cleanline(line: str) -> str:
|
|
13
|
-
"""Remove space and ; from start/end of line.
|
|
14
|
-
|
|
15
|
-
Args:
|
|
16
|
-
line (str): Line to clean.
|
|
17
|
-
|
|
18
|
-
Returns:
|
|
19
|
-
str: Cleaned string.
|
|
20
|
-
"""
|
|
21
|
-
if line == '':
|
|
22
|
-
return line
|
|
23
|
-
|
|
24
|
-
return eml_parser.regexes.cleanline_regex.sub('', line)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def get_domain_ip(line: str) -> list[str]:
|
|
28
|
-
"""Method returns all domains, IPv4 and IPv6 addresses found in a given string.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
line (str): String to search in.
|
|
32
|
-
|
|
33
|
-
Returns:
|
|
34
|
-
list: Unique list of strings with matches
|
|
35
|
-
"""
|
|
36
|
-
m = eml_parser.regexes.dom_regex.findall(' ' + line) + eml_parser.regexes.ipv4_regex.findall(line) + eml_parser.regexes.ipv6_regex.findall(line)
|
|
37
|
-
|
|
38
|
-
return list(set(m))
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def parserouting(line: str, email_regex: regex.Pattern[str] = eml_parser.regexes.email_no_force_tld_regex) -> dict[str, typing.Any]:
|
|
42
|
-
"""This method tries to parse an e-mail header received line and extract machine-readable information.
|
|
43
|
-
|
|
44
|
-
Note that there are a large number of formats for these lines
|
|
45
|
-
and a lot of weird ones which are not commonly used.
|
|
46
|
-
We try our best to match a large number of formats.
|
|
47
|
-
|
|
48
|
-
Args:
|
|
49
|
-
line (str): Received line to be parsed.
|
|
50
|
-
email_regex (regex.Pattern[str], optional): Pattern used to extract e-mail addresses from the 'for' field.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
dict: Returns a dict with the extracted information.
|
|
54
|
-
"""
|
|
55
|
-
# if re.findall(reg_date, line):
|
|
56
|
-
# return 'date\n'
|
|
57
|
-
# Preprocess the line to simplify from/by/with/for border detection.
|
|
58
|
-
out = {} # type: typing.Dict[str, typing.Any] # Result
|
|
59
|
-
out['src'] = line
|
|
60
|
-
line = line.lower() # Convert everything to lowercase
|
|
61
|
-
npline = line.replace(')', ' ) ') # normalise space # Re-space () ")by " exists often
|
|
62
|
-
npline = npline.replace('(', ' ( ') # normalise space # Re-space ()
|
|
63
|
-
npline = npline.replace(';', ' ; ') # normalise space # Re-space ;
|
|
64
|
-
npline = eml_parser.decode.noparenthesis(npline) # Remove any "()"
|
|
65
|
-
npline = ' '.join(npline.split()) # normalise space
|
|
66
|
-
npline = npline.strip('\n') # Remove any new-line
|
|
67
|
-
raw_find_data = eml_parser.regexes.date_regex.findall(npline) # extract date on end line.
|
|
68
|
-
|
|
69
|
-
# Detect "sticked lines"
|
|
70
|
-
if ' received: ' in npline:
|
|
71
|
-
out['warning'] = ['Merged Received headers']
|
|
72
|
-
return out
|
|
73
|
-
|
|
74
|
-
if raw_find_data:
|
|
75
|
-
npdate = raw_find_data[0] # Remove spaces and starting ;
|
|
76
|
-
npdate = npdate.lstrip(';') # Remove Spaces and stating ; from date
|
|
77
|
-
npdate = npdate.strip()
|
|
78
|
-
else:
|
|
79
|
-
npdate = ''
|
|
80
|
-
|
|
81
|
-
npline = npline.replace(npdate, '') # Remove date from input line
|
|
82
|
-
npline = npline.strip(' ') # Remove any border WhiteSpace
|
|
83
|
-
|
|
84
|
-
borders = ['from ', 'by ', 'with ', 'for ']
|
|
85
|
-
|
|
86
|
-
# Scan the line to determine the order, and presence of each "from/by/with/for" words
|
|
87
|
-
raw_matches: list[tuple[int, str]] = []
|
|
88
|
-
for word in borders:
|
|
89
|
-
pos = npline.find(word)
|
|
90
|
-
if pos != -1:
|
|
91
|
-
raw_matches.append((pos, word))
|
|
92
|
-
|
|
93
|
-
if not raw_matches:
|
|
94
|
-
out['warning'] = ['Nothing Parsable']
|
|
95
|
-
return out
|
|
96
|
-
|
|
97
|
-
# Sort keywords sequentially by their appearance order in the header
|
|
98
|
-
tout = sorted(raw_matches, key=lambda match: match[0])
|
|
99
|
-
|
|
100
|
-
reg = ''
|
|
101
|
-
for i, item in enumerate(tout):
|
|
102
|
-
field = item[1].strip()
|
|
103
|
-
kw = item[1]
|
|
104
|
-
|
|
105
|
-
# Capture lazily until the next keyword
|
|
106
|
-
reg += kw + r'(?P<' + field + r'>.*?)'
|
|
107
|
-
|
|
108
|
-
if i + 1 >= len(tout):
|
|
109
|
-
# Last field captures lazily until a date separator ';' or end of string
|
|
110
|
-
reg += '(?:;|$)'
|
|
111
|
-
|
|
112
|
-
reparse = re.compile(reg)
|
|
113
|
-
reparseg = reparse.search(line)
|
|
114
|
-
|
|
115
|
-
# Fill the data
|
|
116
|
-
if reparseg is not None:
|
|
117
|
-
for item in borders: # type: ignore
|
|
118
|
-
try:
|
|
119
|
-
out[item.strip()] = cleanline(reparseg.group(item.strip())) # type: ignore
|
|
120
|
-
except (LookupError, ValueError, AttributeError):
|
|
121
|
-
pass
|
|
122
|
-
|
|
123
|
-
if npdate:
|
|
124
|
-
out['date'] = eml_parser.decode.robust_string2date(npdate)
|
|
125
|
-
|
|
126
|
-
# Fixup for "From" in "for" field
|
|
127
|
-
# ie google, do that...
|
|
128
|
-
if out.get('for'):
|
|
129
|
-
# include spaces in test, otherwise there will be an exception with domains containing "from" in itself
|
|
130
|
-
if ' from ' in out.get('for', ''):
|
|
131
|
-
temp = re.split(' from ', out['for'])
|
|
132
|
-
out['for'] = temp[0]
|
|
133
|
-
out['from'] = f"""{out['from']} {' '.join(temp[1:])}"""
|
|
134
|
-
|
|
135
|
-
m = email_regex.findall(out['for'])
|
|
136
|
-
if m:
|
|
137
|
-
out['for'] = list(set(m))
|
|
138
|
-
else:
|
|
139
|
-
del out['for']
|
|
140
|
-
|
|
141
|
-
# Now.. find IP and Host in from
|
|
142
|
-
if out.get('from'):
|
|
143
|
-
out['from'] = get_domain_ip(out['from'])
|
|
144
|
-
if not out.get('from', []): # if array is empty remove
|
|
145
|
-
del out['from']
|
|
146
|
-
|
|
147
|
-
# Now.. find IP and Host in from
|
|
148
|
-
if out.get('by'):
|
|
149
|
-
out['by'] = get_domain_ip(out['by'])
|
|
150
|
-
if not out.get('by', []): # If array is empty remove
|
|
151
|
-
del out['by']
|
|
152
|
-
|
|
153
|
-
return out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|