email-forward-parser 0.2.0__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.2.0 → email_forward_parser-0.2.1}/.github/dependabot.yml +6 -0
  3. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/.github/workflows/ci.yml +6 -11
  4. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/.github/workflows/workflow.yml +22 -19
  5. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/PKG-INFO +1 -1
  6. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/emailforwardparser/client.py +44 -9
  7. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/pyproject.toml +1 -1
  8. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/tests/regressions_test.py +23 -0
  9. email_forward_parser-0.2.1/uv.lock +495 -0
  10. email_forward_parser-0.2.0/uv.lock +0 -495
  11. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/.gitignore +0 -0
  12. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/LICENSE +0 -0
  13. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/NOTICE +0 -0
  14. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/README.md +0 -0
  15. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/emailforwardparser/__init__.py +0 -0
  16. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/emailforwardparser/forward_parser.py +0 -0
  17. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/emailforwardparser/loop.py +0 -0
  18. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/emailforwardparser/regexs.py +0 -0
  19. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/emailforwardparser/utils.py +0 -0
  20. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/tests/__init__.py +0 -0
  21. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/tests/client_test.py +0 -0
  22. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/tests/coverage_edges_test.py +0 -0
  23. {email_forward_parser-0.2.0 → email_forward_parser-0.2.1}/tests/forward_parser_test.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"
@@ -11,26 +11,21 @@ concurrency:
11
11
  group: ci-${{ github.workflow }}-${{ github.ref }}
12
12
  cancel-in-progress: true
13
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.
14
17
  jobs:
15
18
  verify:
16
- runs-on: ubuntu-latest
19
+ runs-on: ubuntu-24.04
17
20
  timeout-minutes: 30
18
21
  steps:
19
- - uses: actions/checkout@v7
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
23
  - name: Install uv and Python
21
- uses: astral-sh/setup-uv@v10.0.1
24
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
22
25
  with:
23
26
  python-version: "3.14"
24
27
  enable-cache: true
25
28
  - name: Install locked dependencies
26
29
  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
30
  - name: Test with configured coverage gate
36
31
  run: uv run pytest
@@ -11,35 +11,36 @@ permissions:
11
11
  jobs:
12
12
  verify:
13
13
  name: Verify package
14
- runs-on: ubuntu-latest
14
+ runs-on: ubuntu-24.04
15
+ timeout-minutes: 30
15
16
  steps:
16
- - uses: actions/checkout@v7
17
- - uses: astral-sh/setup-uv@v10.0.1
17
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18
+ - name: Install uv and Python
19
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
18
20
  with:
19
21
  python-version: "3.14"
20
22
  enable-cache: true
21
- - run: uv sync --frozen
23
+ - name: Install locked dependencies
24
+ run: uv sync --frozen
22
25
  - name: Verify tag matches package version
23
26
  run: |
24
27
  PACKAGE_VERSION=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
25
28
  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
29
+ - name: Test with configured coverage gate
30
+ run: uv run pytest
31
31
 
32
32
  build:
33
33
  name: Build distribution
34
34
  needs: verify
35
- runs-on: ubuntu-latest
35
+ runs-on: ubuntu-24.04
36
+ timeout-minutes: 10
36
37
 
37
38
  steps:
38
39
  - name: Checkout
39
- uses: actions/checkout@v7
40
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
41
 
41
42
  - name: Set up Python
42
- uses: actions/setup-python@v7
43
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
43
44
  with:
44
45
  python-version: "3.14"
45
46
 
@@ -53,7 +54,7 @@ jobs:
53
54
  run: python -m twine check dist/*
54
55
 
55
56
  - name: Upload distribution artifacts
56
- uses: actions/upload-artifact@v7
57
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
57
58
  with:
58
59
  name: python-package-distributions
59
60
  path: dist/
@@ -61,7 +62,8 @@ jobs:
61
62
  publish:
62
63
  name: Publish distribution to PyPI
63
64
  needs: build
64
- runs-on: ubuntu-latest
65
+ runs-on: ubuntu-24.04
66
+ timeout-minutes: 10
65
67
  environment: pypi
66
68
 
67
69
  permissions:
@@ -70,32 +72,33 @@ jobs:
70
72
 
71
73
  steps:
72
74
  - name: Download distribution artifacts
73
- uses: actions/download-artifact@v8
75
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
74
76
  with:
75
77
  name: python-package-distributions
76
78
  path: dist/
77
79
 
78
80
  - name: Publish package
79
- uses: pypa/gh-action-pypi-publish@release/v1
81
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
80
82
 
81
83
  release:
82
84
  name: Publish GitHub release
83
85
  needs: publish
84
86
  if: startsWith(github.ref, 'refs/tags/')
85
- runs-on: ubuntu-latest
87
+ runs-on: ubuntu-24.04
88
+ timeout-minutes: 10
86
89
 
87
90
  permissions:
88
91
  contents: write
89
92
 
90
93
  steps:
91
94
  - name: Download distribution artifacts
92
- uses: actions/download-artifact@v8
95
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
93
96
  with:
94
97
  name: python-package-distributions
95
98
  path: dist/
96
99
 
97
100
  - name: Publish release
98
- uses: softprops/action-gh-release@v3
101
+ uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
99
102
  with:
100
103
  files: dist/*
101
104
  generate_release_notes: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: email-forward-parser
3
- Version: 0.2.0
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
@@ -4,6 +4,8 @@ import base64
4
4
  import binascii
5
5
  import logging
6
6
  from email import policy
7
+ from email.errors import HeaderParseError
8
+ from email.header import decode_header, make_header
7
9
  from email.message import EmailMessage, Message
8
10
  from email.mime.multipart import MIMEMultipart
9
11
  from email.mime.text import MIMEText
@@ -38,7 +40,7 @@ class EmailParserClient:
38
40
  :rtype: dict
39
41
  """
40
42
  msg = self._parse_message(email)
41
- send_to = self._first_header_address(msg.get("From", ""))
43
+ send_to = self._first_header_address(self._header(msg, "From"))
42
44
  eml = self._get_eml_attachment(msg)
43
45
  if eml:
44
46
  return self._get_dict(eml, fp.OriginalMetadata(), False, send_to=send_to)
@@ -107,9 +109,20 @@ class EmailParserClient:
107
109
  )
108
110
  eml = self._build_original_email(email, source_message).as_string()
109
111
  else:
110
- eml = message.as_string() if isinstance(message, Message) else message
112
+ eml = self._as_string(message) if isinstance(message, Message) else message
111
113
  return {"forward": forwarded, "eml": eml, "Send-To": send_to}
112
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"))
125
+
113
126
  def _build_original_email(
114
127
  self, metadata: fp.OriginalMetadata, message: Message
115
128
  ) -> EmailMessage | MIMEMultipart:
@@ -176,8 +189,7 @@ class EmailParserClient:
176
189
 
177
190
  def _get_forwarded_metadata(self, message: Message) -> fp.ForwardMetadata:
178
191
  body = self._get_body(message)
179
- subject = message.get("Subject")
180
- subject = str(subject) if subject is not None else ""
192
+ subject = self._header(message, "Subject")
181
193
  if subject:
182
194
  return fp.get_forwarded_metadata(body.strip(), subject.strip())
183
195
  return fp.get_forwarded_metadata(body.strip())
@@ -233,16 +245,39 @@ class EmailParserClient:
233
245
  return Parser(policy=policy.default).parsestr(email)
234
246
 
235
247
  def _get_message_metadata(self, message: Message) -> fp.OriginalMetadata:
236
- from_mailboxes = self._mailboxes_from_header(message.get("From", ""))
248
+ from_mailboxes = self._mailboxes_from_header(self._header(message, "From"))
237
249
  return fp.OriginalMetadata(
238
- date=str(message.get("Date", "")).strip(),
239
- subject=str(message.get("Subject", "")).strip(),
250
+ date=self._header(message, "Date").strip(),
251
+ subject=self._header(message, "Subject").strip(),
240
252
  body=self._get_body(message).strip(),
241
253
  from_=from_mailboxes[0] if from_mailboxes else fp.MailboxResult(),
242
- to=self._mailboxes_from_header(message.get("To", "")),
243
- 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")),
244
256
  )
245
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
+
246
281
  def _first_header_address(self, header: object) -> str:
247
282
  mailboxes = self._mailboxes_from_header(header)
248
283
  if not mailboxes:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "email-forward-parser"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Python Library to parse forwarded email messages"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -193,3 +193,26 @@ def test_loop_regexes_match_keeps_earliest_then_first_listed() -> None:
193
193
  assert loop.loop_regexes_match([earlier, later], "abc") == (["b"], earlier)
194
194
  assert loop.loop_regexes_match([later, earlier], "abc") == (["b"], earlier)
195
195
  assert loop.loop_regexes_match([earlier, tie], "bc") == (["b"], earlier)
196
+
197
+
198
+ def test_a_from_whose_encoded_name_decodes_to_a_line_break_is_read_without_raising() -> None:
199
+ """The email package refuses to build an address with a line break in it (ValueError),
200
+ and raised it out of get_original_metadata, failing whoever parsed the email."""
201
+ raw = (
202
+ "From: =?utf-8?q?Jane=0D=0ABcc=3A_evil=40example=2Ecom?= <jane@example.com>\n"
203
+ "To: =?utf-8?q?Bob=0A?= <bob@example.com>, =?x-unknown?q?Ann?= <ann@example.com>\n"
204
+ "Subject: Hello\n\nNo forward here.\n"
205
+ )
206
+ client = EmailParserClient()
207
+
208
+ metadata = client.get_original_metadata(raw)
209
+ assert metadata.forwarded is False
210
+ assert metadata.email.from_ == MailboxResult("Jane Bcc: evil@example.com", "jane@example.com")
211
+ # A name in a charset Python has no codec for stays as it was written.
212
+ assert metadata.email.to == [
213
+ MailboxResult("Bob", "bob@example.com"),
214
+ MailboxResult("=?x-unknown?q?Ann?=", "ann@example.com"),
215
+ ]
216
+ original = client.get_original_eml(raw)
217
+ assert original["Send-To"] == "jane@example.com"
218
+ assert original["eml"].startswith(raw.splitlines()[0])