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.
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/workflows/main.yml +1 -1
- mail_parser-4.9.0/GLOSSARY.md +21 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/PKG-INFO +279 -1
- {mail_parser-4.8.0 → mail_parser-4.9.0}/README.md +278 -0
- mail_parser-4.9.0/docs/adr/0001-bounded-forensic-recovery.md +13 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/addresses.py +2 -1
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/const.py +3 -3
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/core.py +112 -10
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/received.py +94 -0
- mail_parser-4.9.0/src/mailparser/serialization.py +146 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/utils.py +126 -36
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/version.py +1 -1
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/fixtures/corpus_expected.json +11 -10
- mail_parser-4.9.0/tests/test_received_envelope.py +276 -0
- mail_parser-4.9.0/tests/test_security_recovery.py +357 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/uv.lock +678 -296
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/rfc-review/report-template.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/rfc-review/rfc-scope.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/rfc-review/workflow.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-assessment/SKILL.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-assessment/agents/openai.yaml +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-diff/SKILL.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.agents/skills/mail-rfc-diff/agents/openai.yaml +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.claude/agents/security-reviewer.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/FUNDING.yml +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/copilot-instructions.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/containerization-docker-best-practices.instructions.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/github-actions-ci-cd-best-practices.instructions.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/markdown.instructions.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.github/instructions/python.instructions.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.gitignore +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.markdownlint.json +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/.pre-commit-config.yaml +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/AGENTS.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/CLAUDE.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/Dockerfile +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/LICENSE.txt +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/Makefile +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/NOTICE.txt +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/docker-compose.yml +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/docs/images/Bitcoin SpamScope.jpg +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/docs/rfc-review-skills.md +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/pyproject.toml +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/__init__.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/__main__.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/dates.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/src/mailparser/exceptions.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_malformed_1 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_malformed_2 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_malformed_3 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_outlook_1 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_1 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_10 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_11 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_12 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_13 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_14 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_15 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_16 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_17 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_18 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_19 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_2 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_3 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_4 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_5 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_6 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_7 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_8 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/mails/mail_test_9 +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_address_recovery.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_corpus_regression.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_date_recovery.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_encoded_word_context.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_improved_received_patterns.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_inline_body.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_input_octet_preservation.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_international_addresses.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_mail_parser.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_main.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_mime_unicode_metadata.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_received_corpus.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_received_lexical.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/test_utils.py +0 -0
- {mail_parser-4.8.0 → mail_parser-4.9.0}/tests/work_budget.py +0 -0
|
@@ -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.
|
|
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.
|