mail-parser 4.8.0__tar.gz → 4.9.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 (87) hide show
  1. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/workflows/main.yml +1 -1
  2. mail_parser-4.9.0/GLOSSARY.md +21 -0
  3. {mail_parser-4.8.0 → mail_parser-4.9.0}/PKG-INFO +279 -1
  4. {mail_parser-4.8.0 → mail_parser-4.9.0}/README.md +278 -0
  5. mail_parser-4.9.0/docs/adr/0001-bounded-forensic-recovery.md +13 -0
  6. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/addresses.py +2 -1
  7. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/const.py +3 -3
  8. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/core.py +112 -10
  9. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/received.py +94 -0
  10. mail_parser-4.9.0/src/mailparser/serialization.py +146 -0
  11. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/utils.py +126 -36
  12. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/version.py +1 -1
  13. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/fixtures/corpus_expected.json +11 -10
  14. mail_parser-4.9.0/tests/test_received_envelope.py +276 -0
  15. mail_parser-4.9.0/tests/test_security_recovery.py +357 -0
  16. {mail_parser-4.8.0 → mail_parser-4.9.0}/uv.lock +678 -296
  17. {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/rfc-review/report-template.md +0 -0
  18. {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/rfc-review/rfc-scope.md +0 -0
  19. {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/rfc-review/workflow.md +0 -0
  20. {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-assessment/SKILL.md +0 -0
  21. {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-assessment/agents/openai.yaml +0 -0
  22. {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-diff/SKILL.md +0 -0
  23. {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-diff/agents/openai.yaml +0 -0
  24. {mail_parser-4.8.0 → mail_parser-4.9.0}/.claude/agents/security-reviewer.md +0 -0
  25. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/FUNDING.yml +0 -0
  26. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  27. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  28. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/copilot-instructions.md +0 -0
  29. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/containerization-docker-best-practices.instructions.md +0 -0
  30. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/github-actions-ci-cd-best-practices.instructions.md +0 -0
  31. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/markdown.instructions.md +0 -0
  32. {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/python.instructions.md +0 -0
  33. {mail_parser-4.8.0 → mail_parser-4.9.0}/.gitignore +0 -0
  34. {mail_parser-4.8.0 → mail_parser-4.9.0}/.markdownlint.json +0 -0
  35. {mail_parser-4.8.0 → mail_parser-4.9.0}/.pre-commit-config.yaml +0 -0
  36. {mail_parser-4.8.0 → mail_parser-4.9.0}/AGENTS.md +0 -0
  37. {mail_parser-4.8.0 → mail_parser-4.9.0}/CLAUDE.md +0 -0
  38. {mail_parser-4.8.0 → mail_parser-4.9.0}/Dockerfile +0 -0
  39. {mail_parser-4.8.0 → mail_parser-4.9.0}/LICENSE.txt +0 -0
  40. {mail_parser-4.8.0 → mail_parser-4.9.0}/Makefile +0 -0
  41. {mail_parser-4.8.0 → mail_parser-4.9.0}/NOTICE.txt +0 -0
  42. {mail_parser-4.8.0 → mail_parser-4.9.0}/docker-compose.yml +0 -0
  43. {mail_parser-4.8.0 → mail_parser-4.9.0}/docs/images/Bitcoin SpamScope.jpg +0 -0
  44. {mail_parser-4.8.0 → mail_parser-4.9.0}/docs/rfc-review-skills.md +0 -0
  45. {mail_parser-4.8.0 → mail_parser-4.9.0}/pyproject.toml +0 -0
  46. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/__init__.py +0 -0
  47. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/__main__.py +0 -0
  48. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/dates.py +0 -0
  49. {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/exceptions.py +0 -0
  50. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_malformed_1 +0 -0
  51. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_malformed_2 +0 -0
  52. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_malformed_3 +0 -0
  53. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_outlook_1 +0 -0
  54. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_1 +0 -0
  55. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_10 +0 -0
  56. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_11 +0 -0
  57. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_12 +0 -0
  58. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_13 +0 -0
  59. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_14 +0 -0
  60. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_15 +0 -0
  61. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_16 +0 -0
  62. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_17 +0 -0
  63. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_18 +0 -0
  64. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_19 +0 -0
  65. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_2 +0 -0
  66. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_3 +0 -0
  67. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_4 +0 -0
  68. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_5 +0 -0
  69. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_6 +0 -0
  70. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_7 +0 -0
  71. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_8 +0 -0
  72. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_9 +0 -0
  73. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_address_recovery.py +0 -0
  74. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_corpus_regression.py +0 -0
  75. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_date_recovery.py +0 -0
  76. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_encoded_word_context.py +0 -0
  77. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_improved_received_patterns.py +0 -0
  78. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_inline_body.py +0 -0
  79. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_input_octet_preservation.py +0 -0
  80. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_international_addresses.py +0 -0
  81. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_mail_parser.py +0 -0
  82. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_main.py +0 -0
  83. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_mime_unicode_metadata.py +0 -0
  84. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_received_corpus.py +0 -0
  85. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_received_lexical.py +0 -0
  86. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_utils.py +0 -0
  87. {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/work_budget.py +0 -0
@@ -12,7 +12,7 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
  strategy:
14
14
  matrix:
15
- python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.15']
15
+ python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15']
16
16
 
17
17
  steps:
18
18
  - uses: actions/checkout@v4
@@ -0,0 +1,21 @@
1
+ # Mail forensic evidence
2
+
3
+ Language used when describing the evidence recovered from an email.
4
+
5
+ ## Language
6
+
7
+ **Original evidence**:
8
+ The message headers, MIME structure and payloads received for analysis, before
9
+ display decoding or recovery.
10
+
11
+ **Recovered value**:
12
+ A usable interpretation of damaged message content that remains distinguishable
13
+ from its original evidence.
14
+
15
+ **Container representation**:
16
+ A textual copy of the content enclosed by a multipart or attached message,
17
+ which can overlap the evidence in its child attachments.
18
+
19
+ **Partial recovery**:
20
+ A result containing the safely recovered content and explicit diagnostics for
21
+ representations that could not be produced within the analysis budget.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mail-parser
3
- Version: 4.8.0
3
+ Version: 4.9.0
4
4
  Summary: A tool that parses emails by enhancing the Python standard library, extracting all details into a comprehensive object.
5
5
  Project-URL: Homepage, https://github.com/SpamScope/mail-parser
6
6
  Project-URL: Source, https://github.com/SpamScope/mail-parser
@@ -398,6 +398,20 @@ An ambiguous trusted hop stops sender-IP attribution; older headers cannot
398
398
  supply a replacement IP. Balanced, unrecognized obsolete trace syntax remains
399
399
  raw without being declared invalid solely because it is obsolete.
400
400
 
401
+ Envelope annotations (`envelope-from` and `envelope-sender`) are scanned
402
+ without regex backtracking, preserving quoted delimiters, comments, domain
403
+ literals and obsolete routes. Nested or unterminated envelope paths are not
404
+ selected as addresses: `nested-envelope-address` or
405
+ `unclosed-envelope-address` diagnostics retain the complete original field,
406
+ including folding, while other trace clauses remain available. For these
407
+ diagnostics, `recovered` indicates whether a later complete path was recovered
408
+ from an affected clause after the outer path closes. Markers use ASCII
409
+ case equivalence only; Unicode lookalikes remain literal evidence.
410
+ Repeated reasons are aggregated per header to avoid
411
+ duplicating the raw evidence for every malformed fragment. These annotations
412
+ are lexical extractions, not full SMTP mailbox validation; the existing output
413
+ for the valid null sender `<>` remains an absent envelope value.
414
+
401
415
  ### Date diagnostics
402
416
 
403
417
  Impossible calendar dates, clock components and numeric timezone minutes no
@@ -437,8 +451,272 @@ decoder that produces a non-Unicode scalar also triggers recovery: names retain
437
451
  their literal encoded form and text retains a safe byte-derived view, keeping
438
452
  JSON and CLI output encodable as UTF-8.
439
453
 
454
+ Header decoding is strict. An unknown charset, invalid byte sequence or
455
+ non-Unicode scalar leaves the complete original header value literal instead
456
+ of dropping bytes or mixing a decoded prefix with the original. A
457
+ `HeaderDecodeDefect` sets `has_defects`; `mail.header_decoding_defects` contains
458
+ `reason`, `raw`, `header`, zero-based `occurrence`, zero-based MIME `part` and
459
+ `recovered`. Here `recovered=True` means the literal evidence is available,
460
+ not that decoding succeeded. This also covers nested non-address headers;
461
+ address display names retain their existing context-aware diagnostics.
462
+ Malformed encoded attachment names remain literal with `MimeHeaderDefect`.
463
+ Raw Unicode text remains readable alongside encoded-words; decoding leaves
464
+ literal spans intact and removes only ASCII folding whitespace between
465
+ adjacent encoded-words. The optional RFC 2231 language suffix, such as
466
+ `UTF-8*en`, is separated from the charset before strict decoding.
467
+
468
+ ### Resource limits for container representations
469
+
470
+ Attached multipart containers and forwarded messages can repeat the same
471
+ descendant payloads many times. Their optional textual representations share
472
+ a per-message serialization budget. Preflight estimates reject expensive
473
+ subtrees before copying; actual generator visits and writes to all internal
474
+ text/byte buffers are also charged, including failed charset fallbacks.
475
+ The character-work budget is 32 times the original tree's estimated size,
476
+ with a floor of 1 Mi characters and a ceiling of 32 Mi characters. The estimate
477
+ includes raw header/payload lengths and 256 characters of overhead per part.
478
+ The node-visit budget is 16 times the number of MIME parts, with a floor of
479
+ 1,024 and a ceiling of 100,000. These are work units, not an input-size limit
480
+ or an exact process-memory allowance.
481
+
482
+ When a representation cannot fit, it is omitted entirely from `attachments`;
483
+ no empty or truncated file is invented. `ResourceLimitDefect` sets
484
+ `has_defects`, and `mail.resource_limit_defects` records its `reason`, MIME
485
+ `part`, `filename` and `recovered=False`. Child attachments and neighboring
486
+ body parts continue to be recovered under the existing disposition rules.
487
+ The original container remains accessible at
488
+ `list(mail.message.walk())[defect["part"]]` for ordinary MIME-tree parts.
489
+ Recovered epilogue parts have indices after that original tree and remain
490
+ traceable to the original epilogue text. Neither a resource limit nor a
491
+ recovery diagnostic establishes malicious intent or invalid MIME syntax.
492
+
493
+ Both new diagnostic properties always return lists. Nonempty lists appear
494
+ in full and partial JSON output; literal wire headers colliding with those
495
+ names remain accessible through `headers` and `message`. The serialization
496
+ budget applies during parsing to container copies, not to input ingestion,
497
+ individual leaf payloads, or an explicit later `message_as_string` request.
498
+ Applications processing untrusted mail should still bound input size and
499
+ isolate workers according to their own resource constraints.
500
+
440
501
  ## Defects and Their Critical Role in Email Security
441
502
 
503
+ ### Inspecting defects and recovered evidence
504
+
505
+ A defect is a parsing anomaly, an inconsistent value, or a recovery/ambiguity
506
+ diagnostic; it does not by itself establish malicious intent or an RFC violation.
507
+ During parsing, mail-parser collects Python `email` defects from the MIME tree
508
+ and adds its own address, trace, date, charset and MIME-metadata diagnostics:
509
+ `mail.has_defects` becomes `True`, `mail.defects_categories` contains the category
510
+ names, and `mail.defects` contains a list of dictionaries mapping a content type
511
+ (for example `multipart/mixed`) or a diagnostic group (`address-headers`,
512
+ `received-headers`, `date-headers`, `mime-headers`) to lists of textual
513
+ descriptions. `AddressHeaderDefect`, `ReceivedHeaderDefect` and `DateHeaderDefect`
514
+ also populate `mail.address_header_defects`, `mail.received_header_defects` and
515
+ `mail.date_header_defects`, respectively; these properties always exist and are
516
+ empty lists when there are no corresponding diagnostics. Their entries contain
517
+ `reason`, `raw`, `header`, zero-based `occurrence` and `recovered`, plus address
518
+ `candidates` or the selected ISO date `value` where applicable. All occurrences
519
+ are examined, including repeated headers, while address properties and `mail.date`
520
+ retain first-occurrence semantics. `recovered=True` means a value was recovered,
521
+ not that the original input was valid; ambiguous address candidates remain
522
+ evidence rather than selected mailboxes, and valid neighbours remain available.
523
+ `CharsetDecodeDefect`, `MimeHeaderDefect` and collected standard-library defects
524
+ have textual entries in `mail.defects`, without dedicated structured properties;
525
+ the standard-library categories are described in
526
+ [Python's defect reference](https://docs.python.org/3/library/email.errors.html),
527
+ but this is not a guarantee that every defect Python can detect is collected
528
+ (for example, transfer-decoding defects added after the MIME-tree collection may
529
+ remain only in a part's `defects`). Full and partial output always include
530
+ `has_defects`; when defects exist they include `defects` and
531
+ `defects_categories`, with structured header diagnostics included when nonempty
532
+ (JSON represents the category set as a list). Original evidence remains accessible
533
+ through header properties such as `mail.from_raw`, `mail.message`, and individual
534
+ MIME parts. A "bad epilogue" has no separate category or `epilogue_defects`
535
+ property: `StartBoundaryNotFoundDefect` activates an attempt to recover a part
536
+ between the declared boundary markers in the top-level `mail.message.epilogue`,
537
+ and recovered content enters the usual text/attachment outputs without a separate
538
+ recovery-success diagnostic. This is a limited recovery path, not a scan of every
539
+ nested epilogue; an exception during this attempt is logged. An ordinary epilogue
540
+ is permitted by [RFC 2046 section 5.1.1](https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.1)
541
+ and is not itself a defect. The examples below cover each reporting family,
542
+ successful and unsuccessful recovery, repeated headers, multiple simultaneous
543
+ defects and valid controls; `has_defects=False` only means the implemented checks
544
+ found no anomalies, not that the email passed a complete RFC validation.
545
+
546
+ ```python
547
+ import base64
548
+ import json
549
+ import mailparser
550
+
551
+ # 1. Recovered address: all four diagnostic views describe the same problem.
552
+ mail = mailparser.parse_from_string(
553
+ "From: billing@trusted.example < billing@vendor.example >\r\n\r\n"
554
+ )
555
+ assert mail.from_ == [("billing@trusted.example", "billing@vendor.example")]
556
+ assert mail.has_defects is True
557
+ assert "AddressHeaderDefect" in mail.defects_categories
558
+ assert mail.defects == [{"address-headers": [
559
+ "AddressHeaderDefect: from[0]: invalid-display-name"
560
+ ]}]
561
+ assert mail.address_header_defects == [{
562
+ "reason": "invalid-display-name",
563
+ "raw": "billing@trusted.example < billing@vendor.example >",
564
+ "recovered": True,
565
+ "candidates": [{
566
+ "display_name": "billing@trusted.example",
567
+ "address": "billing@vendor.example",
568
+ }],
569
+ "header": "from",
570
+ "occurrence": 0,
571
+ }]
572
+ assert json.loads(mail.mail_json)["address_header_defects"] == (
573
+ mail.address_header_defects
574
+ )
575
+
576
+ # 2. Incomplete address: no selected mailbox; a valid neighbouring field survives.
577
+ mail = mailparser.parse_from_string(
578
+ "From: Alice <alice@example.com\r\n"
579
+ "To: Bob <bob@example.com>\r\n\r\n"
580
+ )
581
+ assert mail.from_ == []
582
+ assert mail.to == [("Bob", "bob@example.com")]
583
+ assert mail.address_header_defects[0]["reason"] == "unclosed-delimiter"
584
+ assert mail.address_header_defects[0]["recovered"] is False
585
+
586
+ # 3. Repeated headers: diagnostics also cover occurrences after the first.
587
+ mail = mailparser.parse_from_string(
588
+ "From: Alice <alice@example.com>\r\n"
589
+ "From: billing@trusted.example <billing@vendor.example>\r\n\r\n"
590
+ )
591
+ assert mail.from_ == [("Alice", "alice@example.com")]
592
+ assert mail.address_header_defects[0]["occurrence"] == 1
593
+
594
+ # 4. Received: ambiguous trace syntax stays raw, with structured diagnostics.
595
+ trace = (
596
+ "from sender.example (unclosed; by mx.example; "
597
+ "1 Jan 2024 12:00:00 +0000"
598
+ )
599
+ mail = mailparser.parse_from_string(f"Received: {trace}\r\n\r\n")
600
+ assert "ReceivedHeaderDefect" in mail.defects_categories
601
+ assert mail.received_header_defects[0]["reason"] == "unclosed-comment"
602
+ assert mail.received_header_defects[0]["recovered"] is False
603
+ assert mail.received[0]["raw"] == trace
604
+ assert "received-headers" in mail.defects[0]
605
+ trace = (
606
+ "from sender.example by mx.example with ESMTP id token; "
607
+ "for <bob@example.com>; 1 Jan 2024 12:00:00 +0000"
608
+ )
609
+ mail = mailparser.parse_from_string(f"Received: {trace}\r\n\r\n")
610
+ assert mail.received_header_defects[0]["reason"] == "misplaced-date-separator"
611
+ assert mail.received_header_defects[0]["recovered"] is True
612
+ assert mail.received[0]["for"] == "<bob@example.com>"
613
+
614
+ # 5. Dates: an impossible date is not selected; a wrong weekday is recoverable.
615
+ mail = mailparser.parse_from_string(
616
+ "Date: 31 Feb 2024 12:00:00 +0000\r\n\r\n"
617
+ )
618
+ assert "DateHeaderDefect" in mail.defects_categories
619
+ assert mail.date is None
620
+ assert mail.date_header_defects[0]["reason"] == "invalid-calendar-date"
621
+ assert mail.date_header_defects[0]["recovered"] is False
622
+ assert "date-headers" in mail.defects[0]
623
+ mail = mailparser.parse_from_string(
624
+ "Date: Tue, 1 Jan 2024 12:00:00 +0000\r\n\r\n"
625
+ )
626
+ assert mail.date.isoformat() == "2024-01-01T12:00:00+00:00"
627
+ assert mail.date_header_defects[0]["reason"] == "weekday-mismatch"
628
+ assert mail.date_header_defects[0]["recovered"] is True
629
+ # Date diagnostics also cover Resent-Date and timestamps inside Received.
630
+ mail = mailparser.parse_from_string(
631
+ "Resent-Date: 31 Feb 2024 12:00:00 +0000\r\n"
632
+ "Received: from sender.example by mx.example; "
633
+ "1 Jan 2024 25:00:00 +0000\r\n\r\n"
634
+ )
635
+ assert {d["header"] for d in mail.date_header_defects} == {
636
+ "resent-date", "received"
637
+ }
638
+
639
+ # 6. Charset: failed declared decoding, then readable UTF-8 or replacement text.
640
+ for payload, expected in [(b"caf\xc3\xa9", "café"), (b"a\xffb", "a\ufffdb")]:
641
+ mail = mailparser.parse_from_bytes(
642
+ b"Content-Type: text/plain; charset=ascii\r\n\r\n" + payload
643
+ )
644
+ assert "CharsetDecodeDefect" in mail.defects_categories
645
+ assert expected in mail.body
646
+ assert "CharsetDecodeDefect:" in mail.defects[0]["text/plain"][0]
647
+ assert mail.message.get_payload(decode=True) == payload
648
+
649
+ # 7. MIME metadata: damaged filename bytes are retained in textual evidence.
650
+ mail = mailparser.parse_from_bytes(
651
+ b"Content-Type: application/octet-stream\r\n"
652
+ b'Content-Disposition: attachment; filename="a\xffb.bin"\r\n'
653
+ b"Content-Transfer-Encoding: base64\r\n\r\nSGVsbG8="
654
+ )
655
+ assert "MimeHeaderDefect" in mail.defects_categories
656
+ assert "\\xff" in " ".join(mail.defects[0]["mime-headers"])
657
+ assert base64.b64decode(mail.attachments[0]["payload"]) == b"Hello"
658
+
659
+ # 8. Standard-library structure defects: missing parameter/start/closing boundary.
660
+ for raw, category in [
661
+ ("Content-Type: multipart/mixed\r\n\r\nHello",
662
+ "NoBoundaryInMultipartDefect"),
663
+ ('Content-Type: multipart/mixed; boundary="b"\r\n\r\nHello',
664
+ "StartBoundaryNotFoundDefect"),
665
+ ('Content-Type: multipart/mixed; boundary="b"\r\n\r\n'
666
+ '--b\r\nContent-Type: text/plain\r\n\r\nHello\r\n',
667
+ "CloseBoundaryNotFoundDefect"),
668
+ ("Subject: example\r\ninvalid header line\r\n\r\nHello",
669
+ "MissingHeaderBodySeparatorDefect"),
670
+ ]:
671
+ mail = mailparser.parse_from_string(raw)
672
+ assert mail.has_defects
673
+ assert category in mail.defects_categories
674
+ assert any(category in text for entry in mail.defects
675
+ for texts in entry.values() for text in texts)
676
+
677
+ # 9. Bad epilogue: reusing the parent's boundary corrupts nested MIME structure.
678
+ # The attachment after the first closing boundary lands in the outer epilogue.
679
+ raw = (
680
+ 'MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary="b"\r\n\r\n'
681
+ '--b\r\nContent-Type: multipart/alternative; boundary="b"\r\n\r\n'
682
+ '--b\r\nContent-Type: text/plain\r\n\r\nHello\r\n--b--\r\n'
683
+ '--b\r\nContent-Type: application/octet-stream; name="evidence.txt"\r\n'
684
+ 'Content-Disposition: attachment; filename="evidence.txt"\r\n'
685
+ 'Content-Transfer-Encoding: base64\r\n\r\nSGVsbG8=\r\n--b--\r\n'
686
+ )
687
+ mail = mailparser.parse_from_string(raw)
688
+ assert mail.has_defects
689
+ assert "StartBoundaryNotFoundDefect" in mail.defects_categories
690
+ assert "multipart/alternative" in mail.defects[0]
691
+ assert "evidence.txt" in mail.message.epilogue
692
+ assert mail.attachments[0]["filename"] == "evidence.txt"
693
+ assert base64.b64decode(mail.attachments[0]["payload"]) == b"Hello"
694
+
695
+ # 10. Multiple families coexist; inspect every entry rather than only defects[0].
696
+ mail = mailparser.parse_from_string(
697
+ "From: billing@trusted.example <billing@vendor.example>\r\n"
698
+ "Date: 31 Feb 2024 12:00:00 +0000\r\n\r\n"
699
+ )
700
+ assert {"AddressHeaderDefect", "DateHeaderDefect"} <= mail.defects_categories
701
+ assert mail.address_header_defects and mail.date_header_defects
702
+
703
+ # 11. Valid controls: obsolete date syntax and an ordinary epilogue are accepted.
704
+ for raw in [
705
+ "From: Alice <alice@example.com>\r\n"
706
+ "Date: 1 Jan 24 12:00:00 GMT\r\n\r\nHello",
707
+ 'Content-Type: multipart/mixed; boundary="b"\r\n\r\n'
708
+ '--b\r\nContent-Type: text/plain\r\n\r\nHello\r\n'
709
+ '--b--\r\nOrdinary epilogue\r\n',
710
+ ]:
711
+ mail = mailparser.parse_from_string(raw)
712
+ assert mail.has_defects is False
713
+ assert mail.defects_categories == set()
714
+ assert mail.defects == []
715
+ assert mail.address_header_defects == []
716
+ assert mail.received_header_defects == []
717
+ assert mail.date_header_defects == []
718
+ ```
719
+
442
720
  Email structural defects are not merely technical curiosities—they represent **potential security
443
721
  vulnerabilities** that sophisticated attackers actively exploit to bypass spam filters, antivirus
444
722
  scanners, and email security gateways.
@@ -366,6 +366,20 @@ An ambiguous trusted hop stops sender-IP attribution; older headers cannot
366
366
  supply a replacement IP. Balanced, unrecognized obsolete trace syntax remains
367
367
  raw without being declared invalid solely because it is obsolete.
368
368
 
369
+ Envelope annotations (`envelope-from` and `envelope-sender`) are scanned
370
+ without regex backtracking, preserving quoted delimiters, comments, domain
371
+ literals and obsolete routes. Nested or unterminated envelope paths are not
372
+ selected as addresses: `nested-envelope-address` or
373
+ `unclosed-envelope-address` diagnostics retain the complete original field,
374
+ including folding, while other trace clauses remain available. For these
375
+ diagnostics, `recovered` indicates whether a later complete path was recovered
376
+ from an affected clause after the outer path closes. Markers use ASCII
377
+ case equivalence only; Unicode lookalikes remain literal evidence.
378
+ Repeated reasons are aggregated per header to avoid
379
+ duplicating the raw evidence for every malformed fragment. These annotations
380
+ are lexical extractions, not full SMTP mailbox validation; the existing output
381
+ for the valid null sender `<>` remains an absent envelope value.
382
+
369
383
  ### Date diagnostics
370
384
 
371
385
  Impossible calendar dates, clock components and numeric timezone minutes no
@@ -405,8 +419,272 @@ decoder that produces a non-Unicode scalar also triggers recovery: names retain
405
419
  their literal encoded form and text retains a safe byte-derived view, keeping
406
420
  JSON and CLI output encodable as UTF-8.
407
421
 
422
+ Header decoding is strict. An unknown charset, invalid byte sequence or
423
+ non-Unicode scalar leaves the complete original header value literal instead
424
+ of dropping bytes or mixing a decoded prefix with the original. A
425
+ `HeaderDecodeDefect` sets `has_defects`; `mail.header_decoding_defects` contains
426
+ `reason`, `raw`, `header`, zero-based `occurrence`, zero-based MIME `part` and
427
+ `recovered`. Here `recovered=True` means the literal evidence is available,
428
+ not that decoding succeeded. This also covers nested non-address headers;
429
+ address display names retain their existing context-aware diagnostics.
430
+ Malformed encoded attachment names remain literal with `MimeHeaderDefect`.
431
+ Raw Unicode text remains readable alongside encoded-words; decoding leaves
432
+ literal spans intact and removes only ASCII folding whitespace between
433
+ adjacent encoded-words. The optional RFC 2231 language suffix, such as
434
+ `UTF-8*en`, is separated from the charset before strict decoding.
435
+
436
+ ### Resource limits for container representations
437
+
438
+ Attached multipart containers and forwarded messages can repeat the same
439
+ descendant payloads many times. Their optional textual representations share
440
+ a per-message serialization budget. Preflight estimates reject expensive
441
+ subtrees before copying; actual generator visits and writes to all internal
442
+ text/byte buffers are also charged, including failed charset fallbacks.
443
+ The character-work budget is 32 times the original tree's estimated size,
444
+ with a floor of 1 Mi characters and a ceiling of 32 Mi characters. The estimate
445
+ includes raw header/payload lengths and 256 characters of overhead per part.
446
+ The node-visit budget is 16 times the number of MIME parts, with a floor of
447
+ 1,024 and a ceiling of 100,000. These are work units, not an input-size limit
448
+ or an exact process-memory allowance.
449
+
450
+ When a representation cannot fit, it is omitted entirely from `attachments`;
451
+ no empty or truncated file is invented. `ResourceLimitDefect` sets
452
+ `has_defects`, and `mail.resource_limit_defects` records its `reason`, MIME
453
+ `part`, `filename` and `recovered=False`. Child attachments and neighboring
454
+ body parts continue to be recovered under the existing disposition rules.
455
+ The original container remains accessible at
456
+ `list(mail.message.walk())[defect["part"]]` for ordinary MIME-tree parts.
457
+ Recovered epilogue parts have indices after that original tree and remain
458
+ traceable to the original epilogue text. Neither a resource limit nor a
459
+ recovery diagnostic establishes malicious intent or invalid MIME syntax.
460
+
461
+ Both new diagnostic properties always return lists. Nonempty lists appear
462
+ in full and partial JSON output; literal wire headers colliding with those
463
+ names remain accessible through `headers` and `message`. The serialization
464
+ budget applies during parsing to container copies, not to input ingestion,
465
+ individual leaf payloads, or an explicit later `message_as_string` request.
466
+ Applications processing untrusted mail should still bound input size and
467
+ isolate workers according to their own resource constraints.
468
+
408
469
  ## Defects and Their Critical Role in Email Security
409
470
 
471
+ ### Inspecting defects and recovered evidence
472
+
473
+ A defect is a parsing anomaly, an inconsistent value, or a recovery/ambiguity
474
+ diagnostic; it does not by itself establish malicious intent or an RFC violation.
475
+ During parsing, mail-parser collects Python `email` defects from the MIME tree
476
+ and adds its own address, trace, date, charset and MIME-metadata diagnostics:
477
+ `mail.has_defects` becomes `True`, `mail.defects_categories` contains the category
478
+ names, and `mail.defects` contains a list of dictionaries mapping a content type
479
+ (for example `multipart/mixed`) or a diagnostic group (`address-headers`,
480
+ `received-headers`, `date-headers`, `mime-headers`) to lists of textual
481
+ descriptions. `AddressHeaderDefect`, `ReceivedHeaderDefect` and `DateHeaderDefect`
482
+ also populate `mail.address_header_defects`, `mail.received_header_defects` and
483
+ `mail.date_header_defects`, respectively; these properties always exist and are
484
+ empty lists when there are no corresponding diagnostics. Their entries contain
485
+ `reason`, `raw`, `header`, zero-based `occurrence` and `recovered`, plus address
486
+ `candidates` or the selected ISO date `value` where applicable. All occurrences
487
+ are examined, including repeated headers, while address properties and `mail.date`
488
+ retain first-occurrence semantics. `recovered=True` means a value was recovered,
489
+ not that the original input was valid; ambiguous address candidates remain
490
+ evidence rather than selected mailboxes, and valid neighbours remain available.
491
+ `CharsetDecodeDefect`, `MimeHeaderDefect` and collected standard-library defects
492
+ have textual entries in `mail.defects`, without dedicated structured properties;
493
+ the standard-library categories are described in
494
+ [Python's defect reference](https://docs.python.org/3/library/email.errors.html),
495
+ but this is not a guarantee that every defect Python can detect is collected
496
+ (for example, transfer-decoding defects added after the MIME-tree collection may
497
+ remain only in a part's `defects`). Full and partial output always include
498
+ `has_defects`; when defects exist they include `defects` and
499
+ `defects_categories`, with structured header diagnostics included when nonempty
500
+ (JSON represents the category set as a list). Original evidence remains accessible
501
+ through header properties such as `mail.from_raw`, `mail.message`, and individual
502
+ MIME parts. A "bad epilogue" has no separate category or `epilogue_defects`
503
+ property: `StartBoundaryNotFoundDefect` activates an attempt to recover a part
504
+ between the declared boundary markers in the top-level `mail.message.epilogue`,
505
+ and recovered content enters the usual text/attachment outputs without a separate
506
+ recovery-success diagnostic. This is a limited recovery path, not a scan of every
507
+ nested epilogue; an exception during this attempt is logged. An ordinary epilogue
508
+ is permitted by [RFC 2046 section 5.1.1](https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.1)
509
+ and is not itself a defect. The examples below cover each reporting family,
510
+ successful and unsuccessful recovery, repeated headers, multiple simultaneous
511
+ defects and valid controls; `has_defects=False` only means the implemented checks
512
+ found no anomalies, not that the email passed a complete RFC validation.
513
+
514
+ ```python
515
+ import base64
516
+ import json
517
+ import mailparser
518
+
519
+ # 1. Recovered address: all four diagnostic views describe the same problem.
520
+ mail = mailparser.parse_from_string(
521
+ "From: billing@trusted.example < billing@vendor.example >\r\n\r\n"
522
+ )
523
+ assert mail.from_ == [("billing@trusted.example", "billing@vendor.example")]
524
+ assert mail.has_defects is True
525
+ assert "AddressHeaderDefect" in mail.defects_categories
526
+ assert mail.defects == [{"address-headers": [
527
+ "AddressHeaderDefect: from[0]: invalid-display-name"
528
+ ]}]
529
+ assert mail.address_header_defects == [{
530
+ "reason": "invalid-display-name",
531
+ "raw": "billing@trusted.example < billing@vendor.example >",
532
+ "recovered": True,
533
+ "candidates": [{
534
+ "display_name": "billing@trusted.example",
535
+ "address": "billing@vendor.example",
536
+ }],
537
+ "header": "from",
538
+ "occurrence": 0,
539
+ }]
540
+ assert json.loads(mail.mail_json)["address_header_defects"] == (
541
+ mail.address_header_defects
542
+ )
543
+
544
+ # 2. Incomplete address: no selected mailbox; a valid neighbouring field survives.
545
+ mail = mailparser.parse_from_string(
546
+ "From: Alice <alice@example.com\r\n"
547
+ "To: Bob <bob@example.com>\r\n\r\n"
548
+ )
549
+ assert mail.from_ == []
550
+ assert mail.to == [("Bob", "bob@example.com")]
551
+ assert mail.address_header_defects[0]["reason"] == "unclosed-delimiter"
552
+ assert mail.address_header_defects[0]["recovered"] is False
553
+
554
+ # 3. Repeated headers: diagnostics also cover occurrences after the first.
555
+ mail = mailparser.parse_from_string(
556
+ "From: Alice <alice@example.com>\r\n"
557
+ "From: billing@trusted.example <billing@vendor.example>\r\n\r\n"
558
+ )
559
+ assert mail.from_ == [("Alice", "alice@example.com")]
560
+ assert mail.address_header_defects[0]["occurrence"] == 1
561
+
562
+ # 4. Received: ambiguous trace syntax stays raw, with structured diagnostics.
563
+ trace = (
564
+ "from sender.example (unclosed; by mx.example; "
565
+ "1 Jan 2024 12:00:00 +0000"
566
+ )
567
+ mail = mailparser.parse_from_string(f"Received: {trace}\r\n\r\n")
568
+ assert "ReceivedHeaderDefect" in mail.defects_categories
569
+ assert mail.received_header_defects[0]["reason"] == "unclosed-comment"
570
+ assert mail.received_header_defects[0]["recovered"] is False
571
+ assert mail.received[0]["raw"] == trace
572
+ assert "received-headers" in mail.defects[0]
573
+ trace = (
574
+ "from sender.example by mx.example with ESMTP id token; "
575
+ "for <bob@example.com>; 1 Jan 2024 12:00:00 +0000"
576
+ )
577
+ mail = mailparser.parse_from_string(f"Received: {trace}\r\n\r\n")
578
+ assert mail.received_header_defects[0]["reason"] == "misplaced-date-separator"
579
+ assert mail.received_header_defects[0]["recovered"] is True
580
+ assert mail.received[0]["for"] == "<bob@example.com>"
581
+
582
+ # 5. Dates: an impossible date is not selected; a wrong weekday is recoverable.
583
+ mail = mailparser.parse_from_string(
584
+ "Date: 31 Feb 2024 12:00:00 +0000\r\n\r\n"
585
+ )
586
+ assert "DateHeaderDefect" in mail.defects_categories
587
+ assert mail.date is None
588
+ assert mail.date_header_defects[0]["reason"] == "invalid-calendar-date"
589
+ assert mail.date_header_defects[0]["recovered"] is False
590
+ assert "date-headers" in mail.defects[0]
591
+ mail = mailparser.parse_from_string(
592
+ "Date: Tue, 1 Jan 2024 12:00:00 +0000\r\n\r\n"
593
+ )
594
+ assert mail.date.isoformat() == "2024-01-01T12:00:00+00:00"
595
+ assert mail.date_header_defects[0]["reason"] == "weekday-mismatch"
596
+ assert mail.date_header_defects[0]["recovered"] is True
597
+ # Date diagnostics also cover Resent-Date and timestamps inside Received.
598
+ mail = mailparser.parse_from_string(
599
+ "Resent-Date: 31 Feb 2024 12:00:00 +0000\r\n"
600
+ "Received: from sender.example by mx.example; "
601
+ "1 Jan 2024 25:00:00 +0000\r\n\r\n"
602
+ )
603
+ assert {d["header"] for d in mail.date_header_defects} == {
604
+ "resent-date", "received"
605
+ }
606
+
607
+ # 6. Charset: failed declared decoding, then readable UTF-8 or replacement text.
608
+ for payload, expected in [(b"caf\xc3\xa9", "café"), (b"a\xffb", "a\ufffdb")]:
609
+ mail = mailparser.parse_from_bytes(
610
+ b"Content-Type: text/plain; charset=ascii\r\n\r\n" + payload
611
+ )
612
+ assert "CharsetDecodeDefect" in mail.defects_categories
613
+ assert expected in mail.body
614
+ assert "CharsetDecodeDefect:" in mail.defects[0]["text/plain"][0]
615
+ assert mail.message.get_payload(decode=True) == payload
616
+
617
+ # 7. MIME metadata: damaged filename bytes are retained in textual evidence.
618
+ mail = mailparser.parse_from_bytes(
619
+ b"Content-Type: application/octet-stream\r\n"
620
+ b'Content-Disposition: attachment; filename="a\xffb.bin"\r\n'
621
+ b"Content-Transfer-Encoding: base64\r\n\r\nSGVsbG8="
622
+ )
623
+ assert "MimeHeaderDefect" in mail.defects_categories
624
+ assert "\\xff" in " ".join(mail.defects[0]["mime-headers"])
625
+ assert base64.b64decode(mail.attachments[0]["payload"]) == b"Hello"
626
+
627
+ # 8. Standard-library structure defects: missing parameter/start/closing boundary.
628
+ for raw, category in [
629
+ ("Content-Type: multipart/mixed\r\n\r\nHello",
630
+ "NoBoundaryInMultipartDefect"),
631
+ ('Content-Type: multipart/mixed; boundary="b"\r\n\r\nHello',
632
+ "StartBoundaryNotFoundDefect"),
633
+ ('Content-Type: multipart/mixed; boundary="b"\r\n\r\n'
634
+ '--b\r\nContent-Type: text/plain\r\n\r\nHello\r\n',
635
+ "CloseBoundaryNotFoundDefect"),
636
+ ("Subject: example\r\ninvalid header line\r\n\r\nHello",
637
+ "MissingHeaderBodySeparatorDefect"),
638
+ ]:
639
+ mail = mailparser.parse_from_string(raw)
640
+ assert mail.has_defects
641
+ assert category in mail.defects_categories
642
+ assert any(category in text for entry in mail.defects
643
+ for texts in entry.values() for text in texts)
644
+
645
+ # 9. Bad epilogue: reusing the parent's boundary corrupts nested MIME structure.
646
+ # The attachment after the first closing boundary lands in the outer epilogue.
647
+ raw = (
648
+ 'MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary="b"\r\n\r\n'
649
+ '--b\r\nContent-Type: multipart/alternative; boundary="b"\r\n\r\n'
650
+ '--b\r\nContent-Type: text/plain\r\n\r\nHello\r\n--b--\r\n'
651
+ '--b\r\nContent-Type: application/octet-stream; name="evidence.txt"\r\n'
652
+ 'Content-Disposition: attachment; filename="evidence.txt"\r\n'
653
+ 'Content-Transfer-Encoding: base64\r\n\r\nSGVsbG8=\r\n--b--\r\n'
654
+ )
655
+ mail = mailparser.parse_from_string(raw)
656
+ assert mail.has_defects
657
+ assert "StartBoundaryNotFoundDefect" in mail.defects_categories
658
+ assert "multipart/alternative" in mail.defects[0]
659
+ assert "evidence.txt" in mail.message.epilogue
660
+ assert mail.attachments[0]["filename"] == "evidence.txt"
661
+ assert base64.b64decode(mail.attachments[0]["payload"]) == b"Hello"
662
+
663
+ # 10. Multiple families coexist; inspect every entry rather than only defects[0].
664
+ mail = mailparser.parse_from_string(
665
+ "From: billing@trusted.example <billing@vendor.example>\r\n"
666
+ "Date: 31 Feb 2024 12:00:00 +0000\r\n\r\n"
667
+ )
668
+ assert {"AddressHeaderDefect", "DateHeaderDefect"} <= mail.defects_categories
669
+ assert mail.address_header_defects and mail.date_header_defects
670
+
671
+ # 11. Valid controls: obsolete date syntax and an ordinary epilogue are accepted.
672
+ for raw in [
673
+ "From: Alice <alice@example.com>\r\n"
674
+ "Date: 1 Jan 24 12:00:00 GMT\r\n\r\nHello",
675
+ 'Content-Type: multipart/mixed; boundary="b"\r\n\r\n'
676
+ '--b\r\nContent-Type: text/plain\r\n\r\nHello\r\n'
677
+ '--b--\r\nOrdinary epilogue\r\n',
678
+ ]:
679
+ mail = mailparser.parse_from_string(raw)
680
+ assert mail.has_defects is False
681
+ assert mail.defects_categories == set()
682
+ assert mail.defects == []
683
+ assert mail.address_header_defects == []
684
+ assert mail.received_header_defects == []
685
+ assert mail.date_header_defects == []
686
+ ```
687
+
410
688
  Email structural defects are not merely technical curiosities—they represent **potential security
411
689
  vulnerabilities** that sophisticated attackers actively exploit to bypass spam filters, antivirus
412
690
  scanners, and email security gateways.
@@ -0,0 +1,13 @@
1
+ # Bound container representations while preserving original evidence
2
+
3
+ Nested attached containers can require repeated copies of the same MIME
4
+ subtrees. Keep existing representations while they fit a per-message work
5
+ budget; omit expensive container representations with explicit diagnostics,
6
+ continue recovering children, and retain the original MIME tree. Do not return
7
+ empty or truncated attachment payloads. This preserves more evidence than
8
+ rejecting the entire message and avoids changing ordinary attachment semantics.
9
+
10
+ When an encoded-word cannot be decoded strictly, retain the complete literal
11
+ header value and diagnose the failure, including its occurrence and MIME part.
12
+ Do not discard undecodable bytes or combine a decoded prefix with the original
13
+ header. A recovered result does not imply RFC compliance or malicious intent.
@@ -12,8 +12,9 @@ import re
12
12
  from dataclasses import dataclass, field
13
13
  from email.header import decode_header
14
14
 
15
+ from mailparser.const import _ENCODED_WORD_RE as _ENCODED_WORD
16
+
15
17
  _FOLD = re.compile(r"\r?\n[ \t]+")
16
- _ENCODED_WORD = re.compile(r"=\?([^?\s]+)\?([bBqQ])\?([^?\s]*)\?=")
17
18
  # Anchored grammar checks: atom, quote and delimiter character sets do not
18
19
  # overlap. In particular, do not search for bare addresses in arbitrary text
19
20
  # or hand long malformed word sequences to headerregistry's quadratic parser.