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