email-forward-parser 0.1.3__tar.gz → 0.2.1__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 (23) hide show
  1. email_forward_parser-0.2.1/.githooks/pre-push +93 -0
  2. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/.github/dependabot.yml +6 -0
  3. email_forward_parser-0.2.1/.github/workflows/ci.yml +31 -0
  4. email_forward_parser-0.2.1/.github/workflows/workflow.yml +104 -0
  5. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/PKG-INFO +6 -18
  6. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/README.md +3 -3
  7. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/client.py +90 -46
  8. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/forward_parser.py +26 -8
  9. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/loop.py +5 -3
  10. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/regexs.py +235 -228
  11. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/utils.py +28 -10
  12. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/pyproject.toml +30 -31
  13. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/tests/client_test.py +6 -5
  14. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/tests/coverage_edges_test.py +151 -53
  15. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/tests/forward_parser_test.py +64 -7
  16. email_forward_parser-0.2.1/tests/regressions_test.py +218 -0
  17. email_forward_parser-0.2.1/uv.lock +495 -0
  18. email_forward_parser-0.1.3/.github/workflows/workflow.yml +0 -81
  19. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/.gitignore +0 -0
  20. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/LICENSE +0 -0
  21. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/NOTICE +0 -0
  22. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/__init__.py +0 -0
  23. {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/tests/__init__.py +0 -0
@@ -0,0 +1,93 @@
1
+ #!/bin/sh
2
+ # Style and type checks on the commits being pushed; GitHub Actions runs the tests.
3
+ # Turn it on once per clone: git config core.hooksPath .githooks
4
+
5
+ fix='uv run black . && uv run isort . && uv run ruff check --fix .'
6
+
7
+ # The same install as CI's "Install locked dependencies".
8
+ setup() {
9
+ uv sync --frozen || return
10
+ }
11
+
12
+ checks() {
13
+ check uv run black --check .
14
+ check uv run isort --check-only .
15
+ check uv run ruff check .
16
+ check uv run mypy
17
+ }
18
+
19
+ # --- the same in every repo from here ---
20
+
21
+ set -u
22
+ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
23
+ common=$(cd "$(git rev-parse --git-common-dir)" && pwd)
24
+ checked="$common/pre-push-checked"
25
+ # A commit passes for this version of the hook: changing the checks checks it again.
26
+ version=$(cksum <"$0" | cut -d' ' -f1)
27
+ # One checkout per clone, kept between pushes so its dependencies and tool caches stay installed.
28
+ cache="${XDG_CACHE_HOME:-$HOME/.cache}/pre-push"
29
+ dir="$cache/$(basename "$(dirname "$common")")-$(printf %s "$common" | cksum | cut -d' ' -f1)"
30
+ lock="$dir.lock"
31
+
32
+ # Run one check and carry on, so a push shows every failure at once.
33
+ check() {
34
+ "$@" || failed=1
35
+ }
36
+
37
+ # Another push in this clone may be using the checkout: wait for it, or take over a lock its push left behind.
38
+ take_lock() {
39
+ mkdir -p "$cache"
40
+ until mkdir "$lock" 2>/dev/null; do
41
+ owner=$(cat "$lock/pid" 2>/dev/null)
42
+ if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then
43
+ rm -rf "$lock"
44
+ continue
45
+ fi
46
+ echo "pre-push: waiting for another push's checks to finish" >&2
47
+ sleep 5
48
+ done
49
+ echo $$ >"$lock/pid"
50
+ }
51
+ trap '[ "$(cat "$lock/pid" 2>/dev/null)" = $$ ] && rm -rf "$lock"' EXIT
52
+ trap 'exit 1' INT TERM
53
+
54
+ # Check out the commit itself, not the working tree: uncommitted files don't go out.
55
+ checkout() {
56
+ if [ -f "$dir/.git" ] && git -C "$dir" checkout --quiet --force --detach "$1" 2>/dev/null; then
57
+ git -C "$dir" clean -fdq
58
+ return
59
+ fi
60
+ # First push, or the folder or its registration went missing: start it again.
61
+ rm -rf "$dir"
62
+ git worktree add --quiet --force --detach "$dir" "$1"
63
+ }
64
+
65
+ status=0
66
+ while read -r _ sha _ _; do
67
+ # A deleted ref sends all zeros, which isn't a commit.
68
+ commit=$(git rev-parse --verify --quiet "$sha^{commit}") || continue
69
+ # Pushing the same commit to the next remote doesn't check it again.
70
+ grep -qsx "$commit $version" "$checked" && continue
71
+
72
+ [ -d "$lock" ] && [ "$(cat "$lock/pid" 2>/dev/null)" = $$ ] || take_lock
73
+ short=$(git rev-parse --short "$commit")
74
+ if ! checkout "$commit"; then
75
+ echo "pre-push: couldn't check out $short in $dir" >&2
76
+ status=1
77
+ continue
78
+ fi
79
+ if ! (cd "$dir" && setup) </dev/null >&2; then
80
+ echo "pre-push: installing the dependencies of $short failed" >&2
81
+ status=1
82
+ continue
83
+ fi
84
+ if (cd "$dir" && failed=0 && checks && exit $failed) </dev/null; then
85
+ echo "$commit $version" >>"$checked"
86
+ else
87
+ echo >&2
88
+ echo "pre-push: checks failed on $short. Style fixes itself with: $fix" >&2
89
+ echo "pre-push: type errors need a code change. Commit the fix and push again (git push --no-verify skips the checks)." >&2
90
+ status=1
91
+ fi
92
+ done
93
+ exit $status
@@ -12,8 +12,14 @@ updates:
12
12
  - "dependencies"
13
13
  - "python"
14
14
 
15
+ # The actions in .github/workflows are pinned to commit SHAs; this keeps the SHAs and their version comments
16
+ # current, in one pull request a week.
15
17
  - package-ecosystem: "github-actions"
16
18
  directory: "/"
19
+ groups:
20
+ github-actions:
21
+ patterns:
22
+ - "*"
17
23
  schedule:
18
24
  interval: "weekly"
19
25
  day: "monday"
@@ -0,0 +1,31 @@
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
+ # Style and type checks run before each push, in .githooks/pre-push; this workflow runs the rest.
15
+ # Runners are pinned to ubuntu-24.04 (ubuntu-latest moves to Ubuntu 26 from 2026-10-19) and actions to commit
16
+ # SHAs, which Dependabot keeps current.
17
+ jobs:
18
+ verify:
19
+ runs-on: ubuntu-24.04
20
+ timeout-minutes: 30
21
+ steps:
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
+ - name: Install uv and Python
24
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
25
+ with:
26
+ python-version: "3.14"
27
+ enable-cache: true
28
+ - name: Install locked dependencies
29
+ run: uv sync --frozen
30
+ - name: Test with configured coverage gate
31
+ run: uv run pytest
@@ -0,0 +1,104 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ verify:
13
+ name: Verify package
14
+ runs-on: ubuntu-24.04
15
+ timeout-minutes: 30
16
+ steps:
17
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18
+ - name: Install uv and Python
19
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
20
+ with:
21
+ python-version: "3.14"
22
+ enable-cache: true
23
+ - name: Install locked dependencies
24
+ run: uv sync --frozen
25
+ - name: Verify tag matches package version
26
+ run: |
27
+ PACKAGE_VERSION=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
28
+ test "v${PACKAGE_VERSION}" = "${GITHUB_REF_NAME}"
29
+ - name: Test with configured coverage gate
30
+ run: uv run pytest
31
+
32
+ build:
33
+ name: Build distribution
34
+ needs: verify
35
+ runs-on: ubuntu-24.04
36
+ timeout-minutes: 10
37
+
38
+ steps:
39
+ - name: Checkout
40
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
41
+
42
+ - name: Set up Python
43
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
44
+ with:
45
+ python-version: "3.14"
46
+
47
+ - name: Install tools
48
+ run: python -m pip install --upgrade build twine
49
+
50
+ - name: Build package
51
+ run: python -m build
52
+
53
+ - name: Check package metadata
54
+ run: python -m twine check dist/*
55
+
56
+ - name: Upload distribution artifacts
57
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
58
+ with:
59
+ name: python-package-distributions
60
+ path: dist/
61
+
62
+ publish:
63
+ name: Publish distribution to PyPI
64
+ needs: build
65
+ runs-on: ubuntu-24.04
66
+ timeout-minutes: 10
67
+ environment: pypi
68
+
69
+ permissions:
70
+ contents: read
71
+ id-token: write
72
+
73
+ steps:
74
+ - name: Download distribution artifacts
75
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
76
+ with:
77
+ name: python-package-distributions
78
+ path: dist/
79
+
80
+ - name: Publish package
81
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
82
+
83
+ release:
84
+ name: Publish GitHub release
85
+ needs: publish
86
+ if: startsWith(github.ref, 'refs/tags/')
87
+ runs-on: ubuntu-24.04
88
+ timeout-minutes: 10
89
+
90
+ permissions:
91
+ contents: write
92
+
93
+ steps:
94
+ - name: Download distribution artifacts
95
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
96
+ with:
97
+ name: python-package-distributions
98
+ path: dist/
99
+
100
+ - name: Publish release
101
+ uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
102
+ with:
103
+ files: dist/*
104
+ generate_release_notes: true
@@ -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.1
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,18 +1,26 @@
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
7
+ from email.errors import HeaderParseError
8
+ from email.header import decode_header, make_header
6
9
  from email.message import EmailMessage, Message
7
10
  from email.mime.multipart import MIMEMultipart
8
11
  from email.mime.text import MIMEText
9
12
  from email.parser import Parser
10
13
  from email.utils import formataddr, getaddresses
11
- from typing import Any
14
+ from typing import TypedDict
12
15
 
13
16
  from emailforwardparser import forward_parser as fp
14
17
 
15
- log = logging.getLogger("emailforwardparser")
18
+ ParsedEmail = TypedDict(
19
+ "ParsedEmail",
20
+ {"forward": bool, "eml": str, "Send-To": str},
21
+ )
22
+
23
+ LOGGER = logging.getLogger(__name__)
16
24
 
17
25
 
18
26
  class EmailParserClient:
@@ -20,7 +28,7 @@ class EmailParserClient:
20
28
  A client for parsing email messages, with support for detecting and handling forwarded emails.
21
29
  """
22
30
 
23
- def get_original_eml(self, email: str) -> dict:
31
+ def get_original_eml(self, email: str) -> ParsedEmail:
24
32
  """
25
33
  Retrieve the original email message as a dict, including metadata and content.
26
34
 
@@ -32,7 +40,7 @@ class EmailParserClient:
32
40
  :rtype: dict
33
41
  """
34
42
  msg = self._parse_message(email)
35
- send_to = self._first_header_address(msg.get("From", ""))
43
+ send_to = self._first_header_address(self._header(msg, "From"))
36
44
  eml = self._get_eml_attachment(msg)
37
45
  if eml:
38
46
  return self._get_dict(eml, fp.OriginalMetadata(), False, send_to=send_to)
@@ -41,7 +49,7 @@ class EmailParserClient:
41
49
  msg, original_metadata.email, original_metadata.forwarded, send_to=send_to
42
50
  )
43
51
 
44
- def get_original_eml_from_file(self, file_path: str) -> dict:
52
+ def get_original_eml_from_file(self, file_path: str) -> ParsedEmail:
45
53
  """
46
54
  Retrieve the original email message, including metadata and content.
47
55
 
@@ -94,21 +102,26 @@ class EmailParserClient:
94
102
 
95
103
  def _get_dict(
96
104
  self, message: Message | str, email: fp.OriginalMetadata, forwarded: bool, send_to: str = ""
97
- ) -> dict:
98
- result: dict[str, Any] = {}
99
- result["forward"] = forwarded
105
+ ) -> ParsedEmail:
100
106
  if forwarded:
101
107
  source_message = (
102
108
  message if isinstance(message, Message) else self._parse_message(message)
103
109
  )
104
- result["eml"] = self._build_original_email(email, source_message).as_string()
110
+ eml = self._build_original_email(email, source_message).as_string()
105
111
  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
112
+ eml = self._as_string(message) if isinstance(message, Message) else message
113
+ return {"forward": forwarded, "eml": eml, "Send-To": send_to}
114
+
115
+ @staticmethod
116
+ def _as_string(message: Message) -> str:
117
+ """The message as text. A header longer than a line is folded again as it is written,
118
+ which builds its mailboxes again and refuses (ValueError) one whose encoded-word name
119
+ decodes to a line break: the message is then written with its headers as they came.
120
+ """
121
+ try:
122
+ return message.as_string()
123
+ except ValueError:
124
+ return message.as_string(policy=policy.default.clone(refold_source="none"))
112
125
 
113
126
  def _build_original_email(
114
127
  self, metadata: fp.OriginalMetadata, message: Message
@@ -124,16 +137,20 @@ class EmailParserClient:
124
137
 
125
138
  mt = MIMEText(metadata.body or "", _subtype="plain", _charset="utf-8")
126
139
  result.attach(mt)
127
- payload = message.get_payload()
128
- if isinstance(payload, list):
129
- for part in payload:
140
+ raw_payload = message.get_payload()
141
+ if isinstance(raw_payload, list):
142
+ pending = list(raw_payload)
143
+ while pending:
144
+ part = pending.pop(0)
130
145
  if isinstance(part, str) or (
131
146
  part.get_content_type() == "text/plain"
132
147
  and "attachment" not in str(part.get("Content-Disposition"))
133
148
  ):
134
149
  continue
135
150
  if part.get_content_subtype() in ["related", "alternative"]:
136
- payload.extend(part.get_payload())
151
+ nested_payload = part.get_payload()
152
+ if isinstance(nested_payload, list):
153
+ pending.extend(nested_payload)
137
154
  continue
138
155
  if part.get_content_type() == "text/html":
139
156
  part.set_payload(part.get_payload())
@@ -142,17 +159,22 @@ class EmailParserClient:
142
159
 
143
160
  def _set_headers(self, metadata: fp.OriginalMetadata, result: EmailMessage | Message) -> None:
144
161
  if metadata.date:
145
- result["Date"] = metadata.date
162
+ result["Date"] = self._header_value(metadata.date)
146
163
  from_header = self._format_addresses([metadata.from_])
147
164
  if from_header:
148
- result["From"] = from_header
165
+ result["From"] = self._header_value(from_header)
149
166
  if metadata.subject:
150
- result["Subject"] = metadata.subject
167
+ result["Subject"] = self._header_value(metadata.subject)
151
168
  to_header = self._format_addresses(metadata.to)
152
169
  if to_header:
153
- result["To"] = to_header
170
+ result["To"] = self._header_value(to_header)
154
171
  if metadata.cc:
155
- result["CC"] = self._format_addresses(metadata.cc)
172
+ result["CC"] = self._header_value(self._format_addresses(metadata.cc))
173
+
174
+ def _header_value(self, value: str) -> str:
175
+ # Parsed values come from message text, which may hold any Unicode line
176
+ # break (for example U+2028); a header value must be a single line.
177
+ return " ".join(value.splitlines())
156
178
 
157
179
  def _format_addresses(self, contacts: list[fp.MailboxResult]) -> str:
158
180
  addresses = []
@@ -167,8 +189,7 @@ class EmailParserClient:
167
189
 
168
190
  def _get_forwarded_metadata(self, message: Message) -> fp.ForwardMetadata:
169
191
  body = self._get_body(message)
170
- subject = message.get("Subject")
171
- subject = str(subject) if subject is not None else ""
192
+ subject = self._header(message, "Subject")
172
193
  if subject:
173
194
  return fp.get_forwarded_metadata(body.strip(), subject.strip())
174
195
  return fp.get_forwarded_metadata(body.strip())
@@ -189,7 +210,7 @@ class EmailParserClient:
189
210
  def _get_eml_attachment(self, message: Message) -> str:
190
211
  for part in message.walk():
191
212
  content_type = part.get_content_type()
192
- if content_type is not None and content_type == "message/rfc822":
213
+ if content_type == "message/rfc822":
193
214
  try:
194
215
  payload = part.get_payload()
195
216
  if isinstance(payload, list) and payload:
@@ -203,10 +224,10 @@ class EmailParserClient:
203
224
  content = get_content()
204
225
  if isinstance(content, Message):
205
226
  return content.as_string()
206
- except Exception:
207
- log.warning("failed to get attached eml, looking for others")
227
+ except LookupError, TypeError, ValueError:
228
+ LOGGER.warning("attached_eml_decode_failed")
208
229
  continue
209
- if content_type is not None and content_type == "application/octet-stream":
230
+ if content_type == "application/octet-stream":
210
231
  file_name = part.get_filename()
211
232
  if file_name and file_name.lower().endswith(".eml"):
212
233
  payload = part.get_payload(decode=True)
@@ -217,23 +238,46 @@ class EmailParserClient:
217
238
  return ""
218
239
 
219
240
  def _get_file_content(self, file_path: str) -> str:
220
- with open(file_path, "r", encoding="utf8") as file:
241
+ with open(file_path, encoding="utf8") as file:
221
242
  return file.read()
222
243
 
223
244
  def _parse_message(self, email: str) -> Message:
224
245
  return Parser(policy=policy.default).parsestr(email)
225
246
 
226
247
  def _get_message_metadata(self, message: Message) -> fp.OriginalMetadata:
227
- from_mailboxes = self._mailboxes_from_header(message.get("From", ""))
248
+ from_mailboxes = self._mailboxes_from_header(self._header(message, "From"))
228
249
  return fp.OriginalMetadata(
229
- date=str(message.get("Date", "")).strip(),
230
- subject=str(message.get("Subject", "")).strip(),
250
+ date=self._header(message, "Date").strip(),
251
+ subject=self._header(message, "Subject").strip(),
231
252
  body=self._get_body(message).strip(),
232
253
  from_=from_mailboxes[0] if from_mailboxes else fp.MailboxResult(),
233
- to=self._mailboxes_from_header(message.get("To", "")),
234
- cc=self._mailboxes_from_header(message.get("Cc", "")),
254
+ to=self._mailboxes_from_header(self._header(message, "To")),
255
+ cc=self._mailboxes_from_header(self._header(message, "Cc")),
235
256
  )
236
257
 
258
+ @staticmethod
259
+ def _header(message: Message, name: str) -> str:
260
+ """The header's decoded value, or "" when the message has none.
261
+
262
+ The email package builds an address header's mailboxes as it reads it, and refuses
263
+ (ValueError) one whose encoded-word name decodes to a line break, so reading such a
264
+ From raised out of the parser. Its mailboxes are read from the raw value instead,
265
+ each name decoded with its line breaks as spaces.
266
+ """
267
+ try:
268
+ value = message.get(name)
269
+ except ValueError:
270
+ raw = next(str(raw) for key, raw in message.raw_items() if key.lower() == name.lower())
271
+ mailboxes = []
272
+ for encoded_name, address in getaddresses([raw]):
273
+ try:
274
+ decoded = str(make_header(decode_header(encoded_name)))
275
+ except HeaderParseError, LookupError, UnicodeError:
276
+ decoded = encoded_name
277
+ mailboxes.append(formataddr((" ".join(decoded.split()), address)))
278
+ return ", ".join(mailboxes)
279
+ return str(value) if value is not None else ""
280
+
237
281
  def _first_header_address(self, header: object) -> str:
238
282
  mailboxes = self._mailboxes_from_header(header)
239
283
  if not mailboxes:
@@ -258,8 +302,8 @@ class EmailParserClient:
258
302
  return content
259
303
  if isinstance(content, bytes):
260
304
  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)
305
+ except LookupError, TypeError, ValueError:
306
+ LOGGER.warning("message_part_decode_failed", exc_info=True)
263
307
 
264
308
  payload = part.get_payload(decode=True)
265
309
  if isinstance(payload, bytes):
@@ -271,20 +315,20 @@ class EmailParserClient:
271
315
  return payload if isinstance(payload, str) else ""
272
316
 
273
317
  def _decode_bytes(self, payload: bytes, charset: str | None = None) -> str:
274
- return payload.decode(charset or "utf-8", errors="replace")
318
+ try:
319
+ return payload.decode(charset or "utf-8", errors="replace")
320
+ except LookupError, UnicodeError:
321
+ # The sender declared a charset Python does not know (for example
322
+ # "unknown-8bit") or a codec that is not a text encoding.
323
+ return payload.decode("utf-8", errors="replace")
275
324
 
276
325
  def get_decoded_str(self, s: str | bytes | None, charset: str | None = None) -> str:
277
326
  if s is None:
278
327
  return ""
279
328
  if isinstance(s, bytes):
280
329
  return self._decode_bytes(s, charset)
281
- if not isinstance(s, str):
282
- return ""
283
-
284
330
  compact = "".join(s.split())
285
331
  try:
286
- return base64.b64decode(compact, validate=True).decode(
287
- charset or "utf-8", errors="replace"
288
- )
289
- except Exception:
332
+ return self._decode_bytes(base64.b64decode(compact, validate=True), charset)
333
+ except binascii.Error, ValueError:
290
334
  return s