Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,15 @@ The `attachments` property returns a list of dictionaries, each containing compr
- `content-id` - Content identifier for referencing within HTML bodies
- `filename` - Original decoded filename from the email. This is untrusted input; never use it
directly to construct a filesystem path.
- `safe_filename` - Filename with directory components removed, or `None` when the original has
no usable basename. When saving attachments, prefer `write_attachments()` for full validation
and collision handling.
- `safe_filename` - Filename with directory components removed and truncated to fit the
filesystem name limit, or `None` when the original has no usable basename. When saving
attachments, prefer `write_attachments()` for full validation and collision handling.
- `mail_content_type` - MIME content type
- `payload` - Base64-encoded attachment data, ready for decoding or storage
- `payload` - Base64-encoded attachment data, ready for decoding or storage. An attachment is
kept as bytes: whatever encoding it was sent with, it is re-encoded to base64 and reports
`base64` as its `content_transfer_encoding`, so the payload always matches the encoding it
declares and hashes like the file the recipient received. Only `base64` parts keep their
original wire text, which is already lossless

To access custom or vendor-specific headers, replace hyphens with underscores. For example, to
access the `X-MSMail-Priority` header:
Expand Down Expand Up @@ -399,7 +403,13 @@ Attachment filenames are supplied by the email sender. The `filename` value in
not pass it directly to `open()` or join it to a directory. The `safe_filename` field provides a
sanitized basename when one exists, but applications saving files should prefer
`write_attachments()`, which also validates containment, rejects symlink destinations, and
deduplicates names within the attachment batch.
deduplicates names within the attachment batch. Deduplication is case-insensitive, because
APFS, exFAT and SMB collapse `Invoice.pdf` and `invoice.pdf` onto a single file.

A single unusable attachment never costs the rest of the batch: `write_attachments()` logs a
warning and moves on when a filename cannot be sanitized, a payload cannot be decoded, or the
write itself fails, so the remaining attachments are still saved. A containment failure is not
treated this way: it raises `MailParserPathError` and stops the batch.

# Usage from Command Line

Expand Down Expand Up @@ -493,7 +503,11 @@ MailParserError: Base MailParser Exception
|
\── MailParserOSError: Raised when there is an OS error
|
\── MailParserPathError: Raised when an attachment escapes the output directory
|
\── MailParserReceivedParsingError: Raised when a received header cannot be parsed
|
\── MailParserRecursionError: Raised when a message is nested too deeply to parse
```

# Docker Deployment
Expand Down
73 changes: 46 additions & 27 deletions src/mailparser/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from mailparser.utils import (
_safe_attachment_filename,
_safe_remove,
as_string_safe,
convert_mail_date,
decode_header_part,
decode_headers,
Expand All @@ -51,6 +52,7 @@
ported_open,
ported_string,
random_string,
raw_payload,
receiveds_parsing,
write_attachments,
)
Expand Down Expand Up @@ -555,42 +557,52 @@ def parse(self):
binary = False
mail_content_type = ported_string(p.get_content_type())
log.debug(f"Mail content type {mail_content_type!r} part {i!r}")
transfer_encoding = ported_string(
p.get("content-transfer-encoding", "")
).lower()
# Strip before comparing, exactly as email's own
# get_payload() does: a trailing space made every
# encoding branch below miss and the raw bytes fall
# through the text path, which drops the non-UTF-8 ones.
transfer_encoding = (
ported_string(p.get("content-transfer-encoding", ""))
.strip()
.lower()
)
log.debug(f"Transfer encoding {transfer_encoding!r} part {i!r}")
content_disposition = ported_string(p.get("content-disposition"))
log.debug(f"content-disposition {content_disposition!r} part {i!r}")

if p.is_multipart():
payload = "".join(
[m.as_string() for m in p.get_payload(decode=False)]
[as_string_safe(m) for m in p.get_payload(decode=False)]
)
binary = False
log.debug(f"Filename {filename!r} part {i!r} is multipart")
elif transfer_encoding == "base64" or (
transfer_encoding == "quoted-printable"
and "application" in mail_content_type
):
payload = p.get_payload(decode=False)
elif transfer_encoding == "base64":
payload = raw_payload(p)
if not isinstance(payload, str):
# The declared charset could not be applied, so
# the wire text is not recoverable: re-encode the
# decoded bytes to keep the payload base64.
payload = base64.b64encode(payload).decode("ascii")
binary = True
log.debug(f"Filename {filename!r} part {i!r} is binary")
elif "uuencode" in transfer_encoding:
# Re-encode in base64
else:
# Every other encoding — uuencode, quoted-printable,
# 7bit, 8bit, binary — is re-encoded to base64 from
# the decoded bytes. An attachment is a file, not
# text: reading it back through a charset dropped
# every byte that charset cannot represent (half of a
# binary payload), so the extracted file was neither
# the attachment nor the bytes on the wire, and its
# hash never matched what the recipient received.
payload = base64.b64encode(p.get_payload(decode=True)).decode(
"ascii"
)
binary = True
transfer_encoding = "base64"
log.debug(
f"Filename {filename!r} part {i!r} is binary (uuencode"
" re-encoded to base64)"
f"Filename {filename!r} part {i!r} is binary"
f" ({transfer_encoding!r} re-encoded to base64)"
)
else:
payload = ported_string(
p.get_payload(decode=True), encoding=charset
)
log.debug(f"Filename {filename!r} part {i!r} is not binary")
transfer_encoding = "base64"

try:
safe_filename = _safe_attachment_filename(filename)
Expand All @@ -616,9 +628,15 @@ def parse(self):
log.debug(f"Email part {i!r} is not an attachment")

payload = p.get_payload(decode=True)
cte = p.get("Content-Transfer-Encoding")
if cte:
cte = cte.lower()
# Strip as well, for the same reason as the attachment
# branch above: a trailing space sent the part down the
# else branch, which re-reads the text through
# raw-unicode-escape and turns it into literal escapes.
cte = (
ported_string(p.get("Content-Transfer-Encoding", ""))
.strip()
.lower()
)

if not cte or cte in ["7bit", "8bit"]:
# message_from_bytes stores non-ASCII body bytes via
Expand All @@ -627,16 +645,17 @@ def parse(self):
# Unicode str (no surrogates). Detect which case we
# have via get_payload(decode=False) and decode
# accordingly so the declared charset is honoured.
raw_str = p.get_payload(decode=False)
raw_str = raw_payload(p)
if isinstance(raw_str, str):
try:
# Raises if surrogates present (from_bytes path)
raw_str.encode("utf-8")
payload = raw_str
except UnicodeEncodeError:
# Recover original bytes then decode with charset
orig_bytes = raw_str.encode("ascii", "surrogateescape")
payload = ported_string(orig_bytes, encoding=charset)
# These encodings are not transformed by
# get_payload(decode=True), so ``payload``
# already holds the original bytes.
payload = ported_string(payload, encoding=charset)
else:
payload = ported_string(payload, encoding=charset)
else:
Expand Down Expand Up @@ -1081,7 +1100,7 @@ def message_as_string(self):
"""
Return the entire message flattened as a string.
"""
return self.message.as_string() if self.message else ""
return as_string_safe(self.message) if self.message else ""

@property
def to_domains(self):
Expand Down
13 changes: 13 additions & 0 deletions src/mailparser/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"MailParserOutlookError",
"MailParserEnvironmentError",
"MailParserOSError",
"MailParserPathError",
"MailParserReceivedParsingError",
"MailParserRecursionError",
)
Expand Down Expand Up @@ -58,6 +59,18 @@ class MailParserOSError(MailParserError):
pass


class MailParserPathError(MailParserError):
"""
Raised when an attachment would be written outside the output directory.

This is a containment failure, not a per-attachment problem, so it keeps
its own type: ``write_attachments()`` skips individual attachments that
cannot be decoded or written, but must never swallow this one.
"""

pass


class MailParserReceivedParsingError(MailParserError):
"""
Raised when a received header cannot be parsed
Expand Down
Loading
Loading