email-forward-parser 0.1.3__tar.gz → 0.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.
- email_forward_parser-0.2.0/.github/workflows/ci.yml +36 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/.github/workflows/workflow.yml +24 -4
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/PKG-INFO +6 -18
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/README.md +3 -3
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/client.py +47 -38
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/forward_parser.py +26 -8
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/loop.py +5 -3
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/regexs.py +235 -228
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/utils.py +28 -10
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/pyproject.toml +30 -31
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/tests/client_test.py +6 -5
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/tests/coverage_edges_test.py +151 -53
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/tests/forward_parser_test.py +64 -7
- email_forward_parser-0.2.0/tests/regressions_test.py +195 -0
- email_forward_parser-0.2.0/uv.lock +495 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/.github/dependabot.yml +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/.gitignore +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/LICENSE +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/NOTICE +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/__init__.py +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: true
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
verify:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
timeout-minutes: 30
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
- name: Install uv and Python
|
|
21
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.14"
|
|
24
|
+
enable-cache: true
|
|
25
|
+
- name: Install locked dependencies
|
|
26
|
+
run: uv sync --frozen
|
|
27
|
+
- name: Check formatting
|
|
28
|
+
run: |
|
|
29
|
+
uv run black --check .
|
|
30
|
+
uv run isort --check-only .
|
|
31
|
+
- name: Lint
|
|
32
|
+
run: uv run ruff check .
|
|
33
|
+
- name: Type check
|
|
34
|
+
run: uv run mypy
|
|
35
|
+
- name: Test with configured coverage gate
|
|
36
|
+
run: uv run pytest
|
|
@@ -4,24 +4,44 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
tags:
|
|
6
6
|
- "v*"
|
|
7
|
-
workflow_dispatch:
|
|
8
7
|
|
|
9
8
|
permissions:
|
|
10
9
|
contents: read
|
|
11
10
|
|
|
12
11
|
jobs:
|
|
12
|
+
verify:
|
|
13
|
+
name: Verify package
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.14"
|
|
20
|
+
enable-cache: true
|
|
21
|
+
- run: uv sync --frozen
|
|
22
|
+
- name: Verify tag matches package version
|
|
23
|
+
run: |
|
|
24
|
+
PACKAGE_VERSION=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
|
|
25
|
+
test "v${PACKAGE_VERSION}" = "${GITHUB_REF_NAME}"
|
|
26
|
+
- run: uv run black --check .
|
|
27
|
+
- run: uv run isort --check-only .
|
|
28
|
+
- run: uv run ruff check .
|
|
29
|
+
- run: uv run mypy
|
|
30
|
+
- run: uv run pytest
|
|
31
|
+
|
|
13
32
|
build:
|
|
14
33
|
name: Build distribution
|
|
34
|
+
needs: verify
|
|
15
35
|
runs-on: ubuntu-latest
|
|
16
36
|
|
|
17
37
|
steps:
|
|
18
38
|
- name: Checkout
|
|
19
|
-
uses: actions/checkout@
|
|
39
|
+
uses: actions/checkout@v7
|
|
20
40
|
|
|
21
41
|
- name: Set up Python
|
|
22
|
-
uses: actions/setup-python@
|
|
42
|
+
uses: actions/setup-python@v7
|
|
23
43
|
with:
|
|
24
|
-
python-version: "3.
|
|
44
|
+
python-version: "3.14"
|
|
25
45
|
|
|
26
46
|
- name: Install tools
|
|
27
47
|
run: python -m pip install --upgrade build twine
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: email-forward-parser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python Library to parse forwarded email messages
|
|
5
5
|
Project-URL: Homepage, https://github.com/felipehertzer/email-forward-parser-py
|
|
6
6
|
Project-URL: Repository, https://github.com/felipehertzer/email-forward-parser-py
|
|
@@ -212,22 +212,10 @@ Keywords: email,eml,forwarded-email,mime,parser
|
|
|
212
212
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
213
213
|
Classifier: Operating System :: OS Independent
|
|
214
214
|
Classifier: Programming Language :: Python :: 3
|
|
215
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
216
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
217
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
218
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
219
215
|
Classifier: Programming Language :: Python :: 3.14
|
|
220
216
|
Classifier: Topic :: Communications :: Email
|
|
221
217
|
Classifier: Topic :: Text Processing
|
|
222
|
-
Requires-Python: >=3.
|
|
223
|
-
Provides-Extra: dev
|
|
224
|
-
Requires-Dist: black>=25; extra == 'dev'
|
|
225
|
-
Requires-Dist: isort>=6; extra == 'dev'
|
|
226
|
-
Requires-Dist: mypy>=1.19; extra == 'dev'
|
|
227
|
-
Requires-Dist: ruff>=0.14; extra == 'dev'
|
|
228
|
-
Provides-Extra: test
|
|
229
|
-
Requires-Dist: pytest-cov>=5; extra == 'test'
|
|
230
|
-
Requires-Dist: pytest>=8; extra == 'test'
|
|
218
|
+
Requires-Python: >=3.14
|
|
231
219
|
Description-Content-Type: text/markdown
|
|
232
220
|
|
|
233
221
|
# email-forward-parser-py
|
|
@@ -250,7 +238,7 @@ need to recover the original sender, recipients, subject, sent date, and body.
|
|
|
250
238
|
`.eml` attachments.
|
|
251
239
|
- Rebuilds an `.eml` string for the original message when a forwarded email is
|
|
252
240
|
detected.
|
|
253
|
-
- Ships with a 100% statement coverage gate.
|
|
241
|
+
- Ships with a 100% statement and branch coverage gate.
|
|
254
242
|
|
|
255
243
|
## Installation
|
|
256
244
|
|
|
@@ -258,7 +246,7 @@ need to recover the original sender, recipients, subject, sent date, and body.
|
|
|
258
246
|
python -m pip install email-forward-parser
|
|
259
247
|
```
|
|
260
248
|
|
|
261
|
-
Python 3.
|
|
249
|
+
Python 3.14 or newer is required.
|
|
262
250
|
|
|
263
251
|
The distribution name is `email-forward-parser`, but the import package is
|
|
264
252
|
`emailforwardparser`:
|
|
@@ -536,7 +524,7 @@ python -m mypy
|
|
|
536
524
|
python -m pytest
|
|
537
525
|
```
|
|
538
526
|
|
|
539
|
-
`pytest` is configured to require 100% statement coverage.
|
|
527
|
+
`pytest` is configured to require 100% statement and branch coverage.
|
|
540
528
|
|
|
541
529
|
## Release
|
|
542
530
|
|
|
@@ -18,7 +18,7 @@ need to recover the original sender, recipients, subject, sent date, and body.
|
|
|
18
18
|
`.eml` attachments.
|
|
19
19
|
- Rebuilds an `.eml` string for the original message when a forwarded email is
|
|
20
20
|
detected.
|
|
21
|
-
- Ships with a 100% statement coverage gate.
|
|
21
|
+
- Ships with a 100% statement and branch coverage gate.
|
|
22
22
|
|
|
23
23
|
## Installation
|
|
24
24
|
|
|
@@ -26,7 +26,7 @@ need to recover the original sender, recipients, subject, sent date, and body.
|
|
|
26
26
|
python -m pip install email-forward-parser
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Python 3.
|
|
29
|
+
Python 3.14 or newer is required.
|
|
30
30
|
|
|
31
31
|
The distribution name is `email-forward-parser`, but the import package is
|
|
32
32
|
`emailforwardparser`:
|
|
@@ -304,7 +304,7 @@ python -m mypy
|
|
|
304
304
|
python -m pytest
|
|
305
305
|
```
|
|
306
306
|
|
|
307
|
-
`pytest` is configured to require 100% statement coverage.
|
|
307
|
+
`pytest` is configured to require 100% statement and branch coverage.
|
|
308
308
|
|
|
309
309
|
## Release
|
|
310
310
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import binascii
|
|
4
5
|
import logging
|
|
5
6
|
from email import policy
|
|
6
7
|
from email.message import EmailMessage, Message
|
|
@@ -8,11 +9,16 @@ from email.mime.multipart import MIMEMultipart
|
|
|
8
9
|
from email.mime.text import MIMEText
|
|
9
10
|
from email.parser import Parser
|
|
10
11
|
from email.utils import formataddr, getaddresses
|
|
11
|
-
from typing import
|
|
12
|
+
from typing import TypedDict
|
|
12
13
|
|
|
13
14
|
from emailforwardparser import forward_parser as fp
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
ParsedEmail = TypedDict(
|
|
17
|
+
"ParsedEmail",
|
|
18
|
+
{"forward": bool, "eml": str, "Send-To": str},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
LOGGER = logging.getLogger(__name__)
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
class EmailParserClient:
|
|
@@ -20,7 +26,7 @@ class EmailParserClient:
|
|
|
20
26
|
A client for parsing email messages, with support for detecting and handling forwarded emails.
|
|
21
27
|
"""
|
|
22
28
|
|
|
23
|
-
def get_original_eml(self, email: str) ->
|
|
29
|
+
def get_original_eml(self, email: str) -> ParsedEmail:
|
|
24
30
|
"""
|
|
25
31
|
Retrieve the original email message as a dict, including metadata and content.
|
|
26
32
|
|
|
@@ -41,7 +47,7 @@ class EmailParserClient:
|
|
|
41
47
|
msg, original_metadata.email, original_metadata.forwarded, send_to=send_to
|
|
42
48
|
)
|
|
43
49
|
|
|
44
|
-
def get_original_eml_from_file(self, file_path: str) ->
|
|
50
|
+
def get_original_eml_from_file(self, file_path: str) -> ParsedEmail:
|
|
45
51
|
"""
|
|
46
52
|
Retrieve the original email message, including metadata and content.
|
|
47
53
|
|
|
@@ -94,21 +100,15 @@ class EmailParserClient:
|
|
|
94
100
|
|
|
95
101
|
def _get_dict(
|
|
96
102
|
self, message: Message | str, email: fp.OriginalMetadata, forwarded: bool, send_to: str = ""
|
|
97
|
-
) ->
|
|
98
|
-
result: dict[str, Any] = {}
|
|
99
|
-
result["forward"] = forwarded
|
|
103
|
+
) -> ParsedEmail:
|
|
100
104
|
if forwarded:
|
|
101
105
|
source_message = (
|
|
102
106
|
message if isinstance(message, Message) else self._parse_message(message)
|
|
103
107
|
)
|
|
104
|
-
|
|
108
|
+
eml = self._build_original_email(email, source_message).as_string()
|
|
105
109
|
else:
|
|
106
|
-
if isinstance(message, Message)
|
|
107
|
-
|
|
108
|
-
else:
|
|
109
|
-
result["eml"] = message
|
|
110
|
-
result["Send-To"] = send_to
|
|
111
|
-
return result
|
|
110
|
+
eml = message.as_string() if isinstance(message, Message) else message
|
|
111
|
+
return {"forward": forwarded, "eml": eml, "Send-To": send_to}
|
|
112
112
|
|
|
113
113
|
def _build_original_email(
|
|
114
114
|
self, metadata: fp.OriginalMetadata, message: Message
|
|
@@ -124,16 +124,20 @@ class EmailParserClient:
|
|
|
124
124
|
|
|
125
125
|
mt = MIMEText(metadata.body or "", _subtype="plain", _charset="utf-8")
|
|
126
126
|
result.attach(mt)
|
|
127
|
-
|
|
128
|
-
if isinstance(
|
|
129
|
-
|
|
127
|
+
raw_payload = message.get_payload()
|
|
128
|
+
if isinstance(raw_payload, list):
|
|
129
|
+
pending = list(raw_payload)
|
|
130
|
+
while pending:
|
|
131
|
+
part = pending.pop(0)
|
|
130
132
|
if isinstance(part, str) or (
|
|
131
133
|
part.get_content_type() == "text/plain"
|
|
132
134
|
and "attachment" not in str(part.get("Content-Disposition"))
|
|
133
135
|
):
|
|
134
136
|
continue
|
|
135
137
|
if part.get_content_subtype() in ["related", "alternative"]:
|
|
136
|
-
|
|
138
|
+
nested_payload = part.get_payload()
|
|
139
|
+
if isinstance(nested_payload, list):
|
|
140
|
+
pending.extend(nested_payload)
|
|
137
141
|
continue
|
|
138
142
|
if part.get_content_type() == "text/html":
|
|
139
143
|
part.set_payload(part.get_payload())
|
|
@@ -142,17 +146,22 @@ class EmailParserClient:
|
|
|
142
146
|
|
|
143
147
|
def _set_headers(self, metadata: fp.OriginalMetadata, result: EmailMessage | Message) -> None:
|
|
144
148
|
if metadata.date:
|
|
145
|
-
result["Date"] = metadata.date
|
|
149
|
+
result["Date"] = self._header_value(metadata.date)
|
|
146
150
|
from_header = self._format_addresses([metadata.from_])
|
|
147
151
|
if from_header:
|
|
148
|
-
result["From"] = from_header
|
|
152
|
+
result["From"] = self._header_value(from_header)
|
|
149
153
|
if metadata.subject:
|
|
150
|
-
result["Subject"] = metadata.subject
|
|
154
|
+
result["Subject"] = self._header_value(metadata.subject)
|
|
151
155
|
to_header = self._format_addresses(metadata.to)
|
|
152
156
|
if to_header:
|
|
153
|
-
result["To"] = to_header
|
|
157
|
+
result["To"] = self._header_value(to_header)
|
|
154
158
|
if metadata.cc:
|
|
155
|
-
result["CC"] = self._format_addresses(metadata.cc)
|
|
159
|
+
result["CC"] = self._header_value(self._format_addresses(metadata.cc))
|
|
160
|
+
|
|
161
|
+
def _header_value(self, value: str) -> str:
|
|
162
|
+
# Parsed values come from message text, which may hold any Unicode line
|
|
163
|
+
# break (for example U+2028); a header value must be a single line.
|
|
164
|
+
return " ".join(value.splitlines())
|
|
156
165
|
|
|
157
166
|
def _format_addresses(self, contacts: list[fp.MailboxResult]) -> str:
|
|
158
167
|
addresses = []
|
|
@@ -189,7 +198,7 @@ class EmailParserClient:
|
|
|
189
198
|
def _get_eml_attachment(self, message: Message) -> str:
|
|
190
199
|
for part in message.walk():
|
|
191
200
|
content_type = part.get_content_type()
|
|
192
|
-
if content_type
|
|
201
|
+
if content_type == "message/rfc822":
|
|
193
202
|
try:
|
|
194
203
|
payload = part.get_payload()
|
|
195
204
|
if isinstance(payload, list) and payload:
|
|
@@ -203,10 +212,10 @@ class EmailParserClient:
|
|
|
203
212
|
content = get_content()
|
|
204
213
|
if isinstance(content, Message):
|
|
205
214
|
return content.as_string()
|
|
206
|
-
except
|
|
207
|
-
|
|
215
|
+
except LookupError, TypeError, ValueError:
|
|
216
|
+
LOGGER.warning("attached_eml_decode_failed")
|
|
208
217
|
continue
|
|
209
|
-
if content_type
|
|
218
|
+
if content_type == "application/octet-stream":
|
|
210
219
|
file_name = part.get_filename()
|
|
211
220
|
if file_name and file_name.lower().endswith(".eml"):
|
|
212
221
|
payload = part.get_payload(decode=True)
|
|
@@ -217,7 +226,7 @@ class EmailParserClient:
|
|
|
217
226
|
return ""
|
|
218
227
|
|
|
219
228
|
def _get_file_content(self, file_path: str) -> str:
|
|
220
|
-
with open(file_path,
|
|
229
|
+
with open(file_path, encoding="utf8") as file:
|
|
221
230
|
return file.read()
|
|
222
231
|
|
|
223
232
|
def _parse_message(self, email: str) -> Message:
|
|
@@ -258,8 +267,8 @@ class EmailParserClient:
|
|
|
258
267
|
return content
|
|
259
268
|
if isinstance(content, bytes):
|
|
260
269
|
return self._decode_bytes(content, part.get_content_charset())
|
|
261
|
-
except
|
|
262
|
-
|
|
270
|
+
except LookupError, TypeError, ValueError:
|
|
271
|
+
LOGGER.warning("message_part_decode_failed", exc_info=True)
|
|
263
272
|
|
|
264
273
|
payload = part.get_payload(decode=True)
|
|
265
274
|
if isinstance(payload, bytes):
|
|
@@ -271,20 +280,20 @@ class EmailParserClient:
|
|
|
271
280
|
return payload if isinstance(payload, str) else ""
|
|
272
281
|
|
|
273
282
|
def _decode_bytes(self, payload: bytes, charset: str | None = None) -> str:
|
|
274
|
-
|
|
283
|
+
try:
|
|
284
|
+
return payload.decode(charset or "utf-8", errors="replace")
|
|
285
|
+
except LookupError, UnicodeError:
|
|
286
|
+
# The sender declared a charset Python does not know (for example
|
|
287
|
+
# "unknown-8bit") or a codec that is not a text encoding.
|
|
288
|
+
return payload.decode("utf-8", errors="replace")
|
|
275
289
|
|
|
276
290
|
def get_decoded_str(self, s: str | bytes | None, charset: str | None = None) -> str:
|
|
277
291
|
if s is None:
|
|
278
292
|
return ""
|
|
279
293
|
if isinstance(s, bytes):
|
|
280
294
|
return self._decode_bytes(s, charset)
|
|
281
|
-
if not isinstance(s, str):
|
|
282
|
-
return ""
|
|
283
|
-
|
|
284
295
|
compact = "".join(s.split())
|
|
285
296
|
try:
|
|
286
|
-
return base64.b64decode(compact, validate=True)
|
|
287
|
-
|
|
288
|
-
)
|
|
289
|
-
except Exception:
|
|
297
|
+
return self._decode_bytes(base64.b64decode(compact, validate=True), charset)
|
|
298
|
+
except binascii.Error, ValueError:
|
|
290
299
|
return s
|
{email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/forward_parser.py
RENAMED
|
@@ -6,7 +6,7 @@ from re import Pattern
|
|
|
6
6
|
from emailforwardparser import loop, regexs, utils
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
@dataclass
|
|
9
|
+
@dataclass(slots=True)
|
|
10
10
|
class MailboxResult:
|
|
11
11
|
"""Class storing the name and address of a mailbox."""
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ class MailboxResult:
|
|
|
14
14
|
address: str = ""
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
@dataclass
|
|
17
|
+
@dataclass(slots=True)
|
|
18
18
|
class ParseBodyResult:
|
|
19
19
|
"""Class storing metadata of parsed forwarded email."""
|
|
20
20
|
|
|
@@ -23,7 +23,7 @@ class ParseBodyResult:
|
|
|
23
23
|
email: str = ""
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
@dataclass
|
|
26
|
+
@dataclass(slots=True)
|
|
27
27
|
class OriginalMetadata:
|
|
28
28
|
"""Class storing metadata of original content in forwarded message."""
|
|
29
29
|
|
|
@@ -35,7 +35,7 @@ class OriginalMetadata:
|
|
|
35
35
|
cc: list[MailboxResult] = field(default_factory=list)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
@dataclass
|
|
38
|
+
@dataclass(slots=True)
|
|
39
39
|
class ForwardMetadata:
|
|
40
40
|
"""Result object storing forwarded email metadata."""
|
|
41
41
|
|
|
@@ -67,7 +67,10 @@ def parse_body(body: str, forwarded: bool) -> ParseBodyResult:
|
|
|
67
67
|
if forwarded:
|
|
68
68
|
match = loop.loop_regexes_split(regexs.ORIGINAL_FROM, body, True)
|
|
69
69
|
if len(match) > 2:
|
|
70
|
-
|
|
70
|
+
# match[1] is the whole From line and match[3] the text after it. The
|
|
71
|
+
# split omits an empty trailing piece, so a body that ends on the From
|
|
72
|
+
# line has no match[3].
|
|
73
|
+
email = match[1] + (match[3] if len(match) > 3 else "")
|
|
71
74
|
return ParseBodyResult(body=body, message=match[0].strip(), email=email.strip())
|
|
72
75
|
return ParseBodyResult()
|
|
73
76
|
|
|
@@ -82,7 +85,10 @@ def parse_original_body(text: str) -> str:
|
|
|
82
85
|
|
|
83
86
|
for regex in regexes:
|
|
84
87
|
match = loop.loop_regexes_split(regex, text, True)
|
|
85
|
-
|
|
88
|
+
# match[3] is the text after the header line, or, when the header occurs
|
|
89
|
+
# again (len(match) > 4), the gap before that next occurrence. Blank lines
|
|
90
|
+
# alone between two occurrences are not a body.
|
|
91
|
+
if len(match) > 3 and match[3].startswith("\n\n") and (len(match) == 4 or match[3].strip()):
|
|
86
92
|
body = match[3]
|
|
87
93
|
return body.strip()
|
|
88
94
|
match = loop.loop_regexes_split(
|
|
@@ -122,8 +128,20 @@ def parse_original_from(text: str, body: str) -> MailboxResult:
|
|
|
122
128
|
|
|
123
129
|
if authors:
|
|
124
130
|
author = authors[0]
|
|
125
|
-
if author.
|
|
131
|
+
if author.address:
|
|
126
132
|
return author
|
|
133
|
+
if author.name:
|
|
134
|
+
# Some clients (e.g. Gmail) wrap a long "From:" line right after the "<",
|
|
135
|
+
# leaving the address on the next line. Rejoin such lines and retry before
|
|
136
|
+
# settling for a name without an address.
|
|
137
|
+
for candidate in (text, body):
|
|
138
|
+
joined = regexs.WRAPPED_ADDRESS_BRACKET.sub(r"\1", candidate)
|
|
139
|
+
if joined == candidate:
|
|
140
|
+
continue
|
|
141
|
+
retry = parse_mailbox(regexs.ORIGINAL_FROM, joined)
|
|
142
|
+
if retry and retry[0].address:
|
|
143
|
+
return retry[0]
|
|
144
|
+
return MailboxResult(author.name.rstrip("<[ ").strip(), "")
|
|
127
145
|
|
|
128
146
|
match, pattern = loop.loop_regexes_match(regexs.SEPARATOR_WITH_INFORMATION, body)
|
|
129
147
|
if len(match) == 4 and pattern is not None:
|
|
@@ -201,7 +219,7 @@ def parse_original_date(text: str, body: str) -> str:
|
|
|
201
219
|
return ""
|
|
202
220
|
|
|
203
221
|
|
|
204
|
-
def parse_mailbox(regexes: list[Pattern], text: str) -> list[MailboxResult]:
|
|
222
|
+
def parse_mailbox(regexes: list[Pattern[str]], text: str) -> list[MailboxResult]:
|
|
205
223
|
match, _ = loop.loop_regexes_match(regexes, text)
|
|
206
224
|
if match:
|
|
207
225
|
mailboxes_line = match[-1].strip()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
1
2
|
from re import Match, Pattern
|
|
2
|
-
from typing import Sequence
|
|
3
3
|
|
|
4
4
|
from .utils import split_with_regexp
|
|
5
5
|
|
|
@@ -48,9 +48,11 @@ def loop_regexes_match(
|
|
|
48
48
|
match = current_match
|
|
49
49
|
match_index = current_match_index
|
|
50
50
|
regex_matched = regex
|
|
51
|
+
if match_index == 0:
|
|
52
|
+
# No later regex can match earlier, and ties keep the first one.
|
|
53
|
+
break
|
|
51
54
|
|
|
52
55
|
# Convert match from None to an empty list if no match is found
|
|
53
56
|
if match is None:
|
|
54
57
|
return [], regex_matched
|
|
55
|
-
|
|
56
|
-
return [match.group(0), *[group or "" for group in match.groups()]], regex_matched
|
|
58
|
+
return [match.group(0), *[group or "" for group in match.groups()]], regex_matched
|