mail-parser 4.6.2__tar.gz → 4.6.4__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 (59) hide show
  1. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/workflows/main.yml +2 -1
  2. {mail_parser-4.6.2 → mail_parser-4.6.4}/PKG-INFO +26 -7
  3. {mail_parser-4.6.2 → mail_parser-4.6.4}/README.md +19 -5
  4. {mail_parser-4.6.2 → mail_parser-4.6.4}/pyproject.toml +8 -1
  5. {mail_parser-4.6.2 → mail_parser-4.6.4}/src/mailparser/core.py +46 -27
  6. {mail_parser-4.6.2 → mail_parser-4.6.4}/src/mailparser/exceptions.py +13 -0
  7. {mail_parser-4.6.2 → mail_parser-4.6.4}/src/mailparser/utils.py +263 -30
  8. {mail_parser-4.6.2 → mail_parser-4.6.4}/src/mailparser/version.py +1 -1
  9. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/test_mail_parser.py +544 -9
  10. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/test_utils.py +42 -4
  11. {mail_parser-4.6.2 → mail_parser-4.6.4}/uv.lock +25 -1
  12. {mail_parser-4.6.2 → mail_parser-4.6.4}/.claude/agents/security-reviewer.md +0 -0
  13. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/FUNDING.yml +0 -0
  14. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/copilot-instructions.md +0 -0
  17. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/instructions/containerization-docker-best-practices.instructions.md +0 -0
  18. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/instructions/github-actions-ci-cd-best-practices.instructions.md +0 -0
  19. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/instructions/markdown.instructions.md +0 -0
  20. {mail_parser-4.6.2 → mail_parser-4.6.4}/.github/instructions/python.instructions.md +0 -0
  21. {mail_parser-4.6.2 → mail_parser-4.6.4}/.gitignore +0 -0
  22. {mail_parser-4.6.2 → mail_parser-4.6.4}/.markdownlint.json +0 -0
  23. {mail_parser-4.6.2 → mail_parser-4.6.4}/.pre-commit-config.yaml +0 -0
  24. {mail_parser-4.6.2 → mail_parser-4.6.4}/CLAUDE.md +0 -0
  25. {mail_parser-4.6.2 → mail_parser-4.6.4}/Dockerfile +0 -0
  26. {mail_parser-4.6.2 → mail_parser-4.6.4}/LICENSE.txt +0 -0
  27. {mail_parser-4.6.2 → mail_parser-4.6.4}/Makefile +0 -0
  28. {mail_parser-4.6.2 → mail_parser-4.6.4}/NOTICE.txt +0 -0
  29. {mail_parser-4.6.2 → mail_parser-4.6.4}/docker-compose.yml +0 -0
  30. {mail_parser-4.6.2 → mail_parser-4.6.4}/docs/images/Bitcoin SpamScope.jpg +0 -0
  31. {mail_parser-4.6.2 → mail_parser-4.6.4}/src/mailparser/__init__.py +0 -0
  32. {mail_parser-4.6.2 → mail_parser-4.6.4}/src/mailparser/__main__.py +0 -0
  33. {mail_parser-4.6.2 → mail_parser-4.6.4}/src/mailparser/const.py +0 -0
  34. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_malformed_1 +0 -0
  35. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_malformed_2 +0 -0
  36. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_malformed_3 +0 -0
  37. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_outlook_1 +0 -0
  38. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_1 +0 -0
  39. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_10 +0 -0
  40. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_11 +0 -0
  41. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_12 +0 -0
  42. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_13 +0 -0
  43. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_14 +0 -0
  44. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_15 +0 -0
  45. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_16 +0 -0
  46. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_17 +0 -0
  47. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_18 +0 -0
  48. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_19 +0 -0
  49. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_2 +0 -0
  50. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_3 +0 -0
  51. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_4 +0 -0
  52. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_5 +0 -0
  53. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_6 +0 -0
  54. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_7 +0 -0
  55. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_8 +0 -0
  56. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/mails/mail_test_9 +0 -0
  57. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/test_improved_received_patterns.py +0 -0
  58. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/test_main.py +0 -0
  59. {mail_parser-4.6.2 → mail_parser-4.6.4}/tests/test_received_corpus.py +0 -0
@@ -12,7 +12,7 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
  strategy:
14
14
  matrix:
15
- python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
15
+ python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.15']
16
16
 
17
17
  steps:
18
18
  - uses: actions/checkout@v4
@@ -23,6 +23,7 @@ jobs:
23
23
  uses: actions/setup-python@v5
24
24
  with:
25
25
  python-version: ${{ matrix.python-version }}
26
+ allow-prereleases: true
26
27
 
27
28
  - name: Install dependencies
28
29
  run: |
@@ -1,7 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mail-parser
3
- Version: 4.6.2
3
+ Version: 4.6.4
4
4
  Summary: A tool that parses emails by enhancing the Python standard library, extracting all details into a comprehensive object.
5
+ Project-URL: Homepage, https://github.com/SpamScope/mail-parser
6
+ Project-URL: Source, https://github.com/SpamScope/mail-parser
7
+ Project-URL: Issues, https://github.com/SpamScope/mail-parser/issues
8
+ Project-URL: Changelog, https://github.com/SpamScope/mail-parser/releases
5
9
  Author-email: Fedele Mantuano <mantuano.fedele@gmail.com>
6
10
  Maintainer-email: Fedele Mantuano <mantuano.fedele@gmail.com>
7
11
  License-Expression: Apache-2.0
@@ -20,7 +24,8 @@ Classifier: Programming Language :: Python :: 3.11
20
24
  Classifier: Programming Language :: Python :: 3.12
21
25
  Classifier: Programming Language :: Python :: 3.13
22
26
  Classifier: Programming Language :: Python :: 3.14
23
- Requires-Python: <3.15,>=3.9
27
+ Classifier: Programming Language :: Python :: 3.15
28
+ Requires-Python: >=3.9
24
29
  Provides-Extra: outlook
25
30
  Requires-Dist: extract-msg>=0.54; extra == 'outlook'
26
31
  Description-Content-Type: text/markdown
@@ -230,11 +235,15 @@ The `attachments` property returns a list of dictionaries, each containing compr
230
235
  - `content-id` - Content identifier for referencing within HTML bodies
231
236
  - `filename` - Original decoded filename from the email. This is untrusted input; never use it
232
237
  directly to construct a filesystem path.
233
- - `safe_filename` - Filename with directory components removed, or `None` when the original has
234
- no usable basename. When saving attachments, prefer `write_attachments()` for full validation
235
- and collision handling.
238
+ - `safe_filename` - Filename with directory components removed and truncated to fit the
239
+ filesystem name limit, or `None` when the original has no usable basename. When saving
240
+ attachments, prefer `write_attachments()` for full validation and collision handling.
236
241
  - `mail_content_type` - MIME content type
237
- - `payload` - Base64-encoded attachment data, ready for decoding or storage
242
+ - `payload` - Base64-encoded attachment data, ready for decoding or storage. An attachment is
243
+ kept as bytes: whatever encoding it was sent with, it is re-encoded to base64 and reports
244
+ `base64` as its `content_transfer_encoding`, so the payload always matches the encoding it
245
+ declares and hashes like the file the recipient received. Only `base64` parts keep their
246
+ original wire text, which is already lossless
238
247
 
239
248
  To access custom or vendor-specific headers, replace hyphens with underscores. For example, to
240
249
  access the `X-MSMail-Priority` header:
@@ -426,7 +435,13 @@ Attachment filenames are supplied by the email sender. The `filename` value in
426
435
  not pass it directly to `open()` or join it to a directory. The `safe_filename` field provides a
427
436
  sanitized basename when one exists, but applications saving files should prefer
428
437
  `write_attachments()`, which also validates containment, rejects symlink destinations, and
429
- deduplicates names within the attachment batch.
438
+ deduplicates names within the attachment batch. Deduplication is case-insensitive, because
439
+ APFS, exFAT and SMB collapse `Invoice.pdf` and `invoice.pdf` onto a single file.
440
+
441
+ A single unusable attachment never costs the rest of the batch: `write_attachments()` logs a
442
+ warning and moves on when a filename cannot be sanitized, a payload cannot be decoded, or the
443
+ write itself fails, so the remaining attachments are still saved. A containment failure is not
444
+ treated this way: it raises `MailParserPathError` and stops the batch.
430
445
 
431
446
  # Usage from Command Line
432
447
 
@@ -520,7 +535,11 @@ MailParserError: Base MailParser Exception
520
535
  |
521
536
  \── MailParserOSError: Raised when there is an OS error
522
537
  |
538
+ \── MailParserPathError: Raised when an attachment escapes the output directory
539
+ |
523
540
  \── MailParserReceivedParsingError: Raised when a received header cannot be parsed
541
+ |
542
+ \── MailParserRecursionError: Raised when a message is nested too deeply to parse
524
543
  ```
525
544
 
526
545
  # Docker Deployment
@@ -203,11 +203,15 @@ The `attachments` property returns a list of dictionaries, each containing compr
203
203
  - `content-id` - Content identifier for referencing within HTML bodies
204
204
  - `filename` - Original decoded filename from the email. This is untrusted input; never use it
205
205
  directly to construct a filesystem path.
206
- - `safe_filename` - Filename with directory components removed, or `None` when the original has
207
- no usable basename. When saving attachments, prefer `write_attachments()` for full validation
208
- and collision handling.
206
+ - `safe_filename` - Filename with directory components removed and truncated to fit the
207
+ filesystem name limit, or `None` when the original has no usable basename. When saving
208
+ attachments, prefer `write_attachments()` for full validation and collision handling.
209
209
  - `mail_content_type` - MIME content type
210
- - `payload` - Base64-encoded attachment data, ready for decoding or storage
210
+ - `payload` - Base64-encoded attachment data, ready for decoding or storage. An attachment is
211
+ kept as bytes: whatever encoding it was sent with, it is re-encoded to base64 and reports
212
+ `base64` as its `content_transfer_encoding`, so the payload always matches the encoding it
213
+ declares and hashes like the file the recipient received. Only `base64` parts keep their
214
+ original wire text, which is already lossless
211
215
 
212
216
  To access custom or vendor-specific headers, replace hyphens with underscores. For example, to
213
217
  access the `X-MSMail-Priority` header:
@@ -399,7 +403,13 @@ Attachment filenames are supplied by the email sender. The `filename` value in
399
403
  not pass it directly to `open()` or join it to a directory. The `safe_filename` field provides a
400
404
  sanitized basename when one exists, but applications saving files should prefer
401
405
  `write_attachments()`, which also validates containment, rejects symlink destinations, and
402
- deduplicates names within the attachment batch.
406
+ deduplicates names within the attachment batch. Deduplication is case-insensitive, because
407
+ APFS, exFAT and SMB collapse `Invoice.pdf` and `invoice.pdf` onto a single file.
408
+
409
+ A single unusable attachment never costs the rest of the batch: `write_attachments()` logs a
410
+ warning and moves on when a filename cannot be sanitized, a payload cannot be decoded, or the
411
+ write itself fails, so the remaining attachments are still saved. A containment failure is not
412
+ treated this way: it raises `MailParserPathError` and stops the batch.
403
413
 
404
414
  # Usage from Command Line
405
415
 
@@ -493,7 +503,11 @@ MailParserError: Base MailParser Exception
493
503
  |
494
504
  \── MailParserOSError: Raised when there is an OS error
495
505
  |
506
+ \── MailParserPathError: Raised when an attachment escapes the output directory
507
+ |
496
508
  \── MailParserReceivedParsingError: Raised when a received header cannot be parsed
509
+ |
510
+ \── MailParserRecursionError: Raised when a message is nested too deeply to parse
497
511
  ```
498
512
 
499
513
  # Docker Deployment
@@ -4,7 +4,7 @@ dynamic = ["version"]
4
4
  description = "A tool that parses emails by enhancing the Python standard library, extracting all details into a comprehensive object."
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
7
- requires-python = ">=3.9,<3.15"
7
+ requires-python = ">=3.9"
8
8
  keywords = ["email", "mail", "parser", "security", "forensics", "threat detection", "phishing", "malware", "spam"]
9
9
  classifiers = [
10
10
  "Natural Language :: English",
@@ -19,6 +19,7 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3.12",
20
20
  "Programming Language :: Python :: 3.13",
21
21
  "Programming Language :: Python :: 3.14",
22
+ "Programming Language :: Python :: 3.15",
22
23
  ]
23
24
  authors = [
24
25
  { name = "Fedele Mantuano", email = "mantuano.fedele@gmail.com" }
@@ -28,6 +29,12 @@ maintainers = [
28
29
  ]
29
30
  dependencies = []
30
31
 
32
+ [project.urls]
33
+ Homepage = "https://github.com/SpamScope/mail-parser"
34
+ Source = "https://github.com/SpamScope/mail-parser"
35
+ Issues = "https://github.com/SpamScope/mail-parser/issues"
36
+ Changelog = "https://github.com/SpamScope/mail-parser/releases"
37
+
31
38
  [project.optional-dependencies]
32
39
  outlook = ["extract-msg>=0.54"]
33
40
 
@@ -36,6 +36,7 @@ from mailparser.exceptions import MailParserRecursionError
36
36
  from mailparser.utils import (
37
37
  _safe_attachment_filename,
38
38
  _safe_remove,
39
+ as_string_safe,
39
40
  convert_mail_date,
40
41
  decode_header_part,
41
42
  decode_headers,
@@ -51,6 +52,7 @@ from mailparser.utils import (
51
52
  ported_open,
52
53
  ported_string,
53
54
  random_string,
55
+ raw_payload,
54
56
  receiveds_parsing,
55
57
  write_attachments,
56
58
  )
@@ -555,42 +557,52 @@ class MailParser:
555
557
  binary = False
556
558
  mail_content_type = ported_string(p.get_content_type())
557
559
  log.debug(f"Mail content type {mail_content_type!r} part {i!r}")
558
- transfer_encoding = ported_string(
559
- p.get("content-transfer-encoding", "")
560
- ).lower()
560
+ # Strip before comparing, exactly as email's own
561
+ # get_payload() does: a trailing space made every
562
+ # encoding branch below miss and the raw bytes fall
563
+ # through the text path, which drops the non-UTF-8 ones.
564
+ transfer_encoding = (
565
+ ported_string(p.get("content-transfer-encoding", ""))
566
+ .strip()
567
+ .lower()
568
+ )
561
569
  log.debug(f"Transfer encoding {transfer_encoding!r} part {i!r}")
562
570
  content_disposition = ported_string(p.get("content-disposition"))
563
571
  log.debug(f"content-disposition {content_disposition!r} part {i!r}")
564
572
 
565
573
  if p.is_multipart():
566
574
  payload = "".join(
567
- [m.as_string() for m in p.get_payload(decode=False)]
575
+ [as_string_safe(m) for m in p.get_payload(decode=False)]
568
576
  )
569
577
  binary = False
570
578
  log.debug(f"Filename {filename!r} part {i!r} is multipart")
571
- elif transfer_encoding == "base64" or (
572
- transfer_encoding == "quoted-printable"
573
- and "application" in mail_content_type
574
- ):
575
- payload = p.get_payload(decode=False)
579
+ elif transfer_encoding == "base64":
580
+ payload = raw_payload(p)
581
+ if not isinstance(payload, str):
582
+ # The declared charset could not be applied, so
583
+ # the wire text is not recoverable: re-encode the
584
+ # decoded bytes to keep the payload base64.
585
+ payload = base64.b64encode(payload).decode("ascii")
576
586
  binary = True
577
587
  log.debug(f"Filename {filename!r} part {i!r} is binary")
578
- elif "uuencode" in transfer_encoding:
579
- # Re-encode in base64
588
+ else:
589
+ # Every other encoding — uuencode, quoted-printable,
590
+ # 7bit, 8bit, binary — is re-encoded to base64 from
591
+ # the decoded bytes. An attachment is a file, not
592
+ # text: reading it back through a charset dropped
593
+ # every byte that charset cannot represent (half of a
594
+ # binary payload), so the extracted file was neither
595
+ # the attachment nor the bytes on the wire, and its
596
+ # hash never matched what the recipient received.
580
597
  payload = base64.b64encode(p.get_payload(decode=True)).decode(
581
598
  "ascii"
582
599
  )
583
600
  binary = True
584
- transfer_encoding = "base64"
585
601
  log.debug(
586
- f"Filename {filename!r} part {i!r} is binary (uuencode"
587
- " re-encoded to base64)"
602
+ f"Filename {filename!r} part {i!r} is binary"
603
+ f" ({transfer_encoding!r} re-encoded to base64)"
588
604
  )
589
- else:
590
- payload = ported_string(
591
- p.get_payload(decode=True), encoding=charset
592
- )
593
- log.debug(f"Filename {filename!r} part {i!r} is not binary")
605
+ transfer_encoding = "base64"
594
606
 
595
607
  try:
596
608
  safe_filename = _safe_attachment_filename(filename)
@@ -616,9 +628,15 @@ class MailParser:
616
628
  log.debug(f"Email part {i!r} is not an attachment")
617
629
 
618
630
  payload = p.get_payload(decode=True)
619
- cte = p.get("Content-Transfer-Encoding")
620
- if cte:
621
- cte = cte.lower()
631
+ # Strip as well, for the same reason as the attachment
632
+ # branch above: a trailing space sent the part down the
633
+ # else branch, which re-reads the text through
634
+ # raw-unicode-escape and turns it into literal escapes.
635
+ cte = (
636
+ ported_string(p.get("Content-Transfer-Encoding", ""))
637
+ .strip()
638
+ .lower()
639
+ )
622
640
 
623
641
  if not cte or cte in ["7bit", "8bit"]:
624
642
  # message_from_bytes stores non-ASCII body bytes via
@@ -627,16 +645,17 @@ class MailParser:
627
645
  # Unicode str (no surrogates). Detect which case we
628
646
  # have via get_payload(decode=False) and decode
629
647
  # accordingly so the declared charset is honoured.
630
- raw_str = p.get_payload(decode=False)
648
+ raw_str = raw_payload(p)
631
649
  if isinstance(raw_str, str):
632
650
  try:
633
651
  # Raises if surrogates present (from_bytes path)
634
652
  raw_str.encode("utf-8")
635
653
  payload = raw_str
636
654
  except UnicodeEncodeError:
637
- # Recover original bytes then decode with charset
638
- orig_bytes = raw_str.encode("ascii", "surrogateescape")
639
- payload = ported_string(orig_bytes, encoding=charset)
655
+ # These encodings are not transformed by
656
+ # get_payload(decode=True), so ``payload``
657
+ # already holds the original bytes.
658
+ payload = ported_string(payload, encoding=charset)
640
659
  else:
641
660
  payload = ported_string(payload, encoding=charset)
642
661
  else:
@@ -1081,7 +1100,7 @@ class MailParser:
1081
1100
  """
1082
1101
  Return the entire message flattened as a string.
1083
1102
  """
1084
- return self.message.as_string() if self.message else ""
1103
+ return as_string_safe(self.message) if self.message else ""
1085
1104
 
1086
1105
  @property
1087
1106
  def to_domains(self):
@@ -21,6 +21,7 @@ __all__ = (
21
21
  "MailParserOutlookError",
22
22
  "MailParserEnvironmentError",
23
23
  "MailParserOSError",
24
+ "MailParserPathError",
24
25
  "MailParserReceivedParsingError",
25
26
  "MailParserRecursionError",
26
27
  )
@@ -58,6 +59,18 @@ class MailParserOSError(MailParserError):
58
59
  pass
59
60
 
60
61
 
62
+ class MailParserPathError(MailParserError):
63
+ """
64
+ Raised when an attachment would be written outside the output directory.
65
+
66
+ This is a containment failure, not a per-attachment problem, so it keeps
67
+ its own type: ``write_attachments()`` skips individual attachments that
68
+ cannot be decoded or written, but must never swallow this one.
69
+ """
70
+
71
+ pass
72
+
73
+
61
74
  class MailParserReceivedParsingError(MailParserError):
62
75
  """
63
76
  Raised when a received header cannot be parsed