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.
- email_forward_parser-0.2.1/.githooks/pre-push +93 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/.github/dependabot.yml +6 -0
- email_forward_parser-0.2.1/.github/workflows/ci.yml +31 -0
- email_forward_parser-0.2.1/.github/workflows/workflow.yml +104 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/PKG-INFO +6 -18
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/README.md +3 -3
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/client.py +90 -46
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/forward_parser.py +26 -8
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/loop.py +5 -3
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/regexs.py +235 -228
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/utils.py +28 -10
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/pyproject.toml +30 -31
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/tests/client_test.py +6 -5
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/tests/coverage_edges_test.py +151 -53
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/tests/forward_parser_test.py +64 -7
- email_forward_parser-0.2.1/tests/regressions_test.py +218 -0
- email_forward_parser-0.2.1/uv.lock +495 -0
- email_forward_parser-0.1.3/.github/workflows/workflow.yml +0 -81
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/.gitignore +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/LICENSE +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/NOTICE +0 -0
- {email_forward_parser-0.1.3 → email_forward_parser-0.2.1}/emailforwardparser/__init__.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: email-forward-parser
|
|
3
|
-
Version: 0.1
|
|
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.
|
|
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,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
|
|
14
|
+
from typing import TypedDict
|
|
12
15
|
|
|
13
16
|
from emailforwardparser import forward_parser as fp
|
|
14
17
|
|
|
15
|
-
|
|
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) ->
|
|
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(
|
|
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) ->
|
|
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
|
-
) ->
|
|
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
|
-
|
|
110
|
+
eml = self._build_original_email(email, source_message).as_string()
|
|
105
111
|
else:
|
|
106
|
-
if isinstance(message, Message)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
128
|
-
if isinstance(
|
|
129
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
207
|
-
|
|
227
|
+
except LookupError, TypeError, ValueError:
|
|
228
|
+
LOGGER.warning("attached_eml_decode_failed")
|
|
208
229
|
continue
|
|
209
|
-
if content_type
|
|
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,
|
|
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(
|
|
248
|
+
from_mailboxes = self._mailboxes_from_header(self._header(message, "From"))
|
|
228
249
|
return fp.OriginalMetadata(
|
|
229
|
-
date=
|
|
230
|
-
subject=
|
|
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(
|
|
234
|
-
cc=self._mailboxes_from_header(
|
|
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
|
|
262
|
-
|
|
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
|
-
|
|
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)
|
|
287
|
-
|
|
288
|
-
)
|
|
289
|
-
except Exception:
|
|
332
|
+
return self._decode_bytes(base64.b64decode(compact, validate=True), charset)
|
|
333
|
+
except binascii.Error, ValueError:
|
|
290
334
|
return s
|