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.
Files changed (21) hide show
  1. email_forward_parser-0.2.0/.github/workflows/ci.yml +36 -0
  2. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/.github/workflows/workflow.yml +24 -4
  3. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/PKG-INFO +6 -18
  4. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/README.md +3 -3
  5. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/client.py +47 -38
  6. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/forward_parser.py +26 -8
  7. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/loop.py +5 -3
  8. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/regexs.py +235 -228
  9. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/utils.py +28 -10
  10. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/pyproject.toml +30 -31
  11. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/tests/client_test.py +6 -5
  12. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/tests/coverage_edges_test.py +151 -53
  13. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/tests/forward_parser_test.py +64 -7
  14. email_forward_parser-0.2.0/tests/regressions_test.py +195 -0
  15. email_forward_parser-0.2.0/uv.lock +495 -0
  16. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/.github/dependabot.yml +0 -0
  17. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/.gitignore +0 -0
  18. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/LICENSE +0 -0
  19. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/NOTICE +0 -0
  20. {email_forward_parser-0.1.3 → email_forward_parser-0.2.0}/emailforwardparser/__init__.py +0 -0
  21. {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@v6
39
+ uses: actions/checkout@v7
20
40
 
21
41
  - name: Set up Python
22
- uses: actions/setup-python@v6
42
+ uses: actions/setup-python@v7
23
43
  with:
24
- python-version: "3.12"
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: email-forward-parser
3
- Version: 0.1.3
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.10
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.10 or newer is required.
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.10 or newer is required.
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 Any
12
+ from typing import TypedDict
12
13
 
13
14
  from emailforwardparser import forward_parser as fp
14
15
 
15
- log = logging.getLogger("emailforwardparser")
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) -> dict:
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) -> dict:
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
- ) -> dict:
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
- result["eml"] = self._build_original_email(email, source_message).as_string()
108
+ eml = self._build_original_email(email, source_message).as_string()
105
109
  else:
106
- if isinstance(message, Message):
107
- result["eml"] = message.as_string()
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
- payload = message.get_payload()
128
- if isinstance(payload, list):
129
- for part in payload:
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
- payload.extend(part.get_payload())
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 is not None and content_type == "message/rfc822":
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 Exception:
207
- log.warning("failed to get attached eml, looking for others")
215
+ except LookupError, TypeError, ValueError:
216
+ LOGGER.warning("attached_eml_decode_failed")
208
217
  continue
209
- if content_type is not None and content_type == "application/octet-stream":
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, "r", encoding="utf8") as file:
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 Exception:
262
- log.warning("failed to decode message part with email policy", exc_info=True)
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
- return payload.decode(charset or "utf-8", errors="replace")
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).decode(
287
- charset or "utf-8", errors="replace"
288
- )
289
- except Exception:
297
+ return self._decode_bytes(base64.b64decode(compact, validate=True), charset)
298
+ except binascii.Error, ValueError:
290
299
  return s
@@ -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
- email = "".join(match[i] for i in [1, 3])
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
- if len(match) > 3 and match[3].startswith("\n\n"):
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.name or author.address:
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
- else:
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