Attachments stored once; nightly clean-up of unused files
Attachments are no longer stored apart from their message (they were kept twice: base64 inside the raw message and decoded on their own). The indexer records the MIME part (attachments.part_index, migration 0023); downloads, zip, inline images, thumbnails and forwarding take it out of the message; videos stream from a short-lived copy (kept a day, 1 GiB per user). A background pass on worker start removes the copies stored before, each only after checking it can be taken out again (spec App. D §D.6 item 1, criterion corrected to <= 60%). A nightly job (03:47, after the Trash/Spam purge) removes files nothing refers to any more — deleted or purged messages, previews of attachments gone, uploads no draft has used for a week, expired streaming copies. Files younger than an hour are left alone; a sent message waiting for its Sent copy is held until its job is done. Account removal keeps files a draft still uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,9 @@ class ParsedAttachment:
|
||||
mime_type: str
|
||||
content_id: str | None
|
||||
payload: bytes
|
||||
# The part's position in ``msg.walk()``: where to find it again in the raw
|
||||
# message, which is the only copy kept (attachments are not stored apart).
|
||||
part_index: int = -1
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -114,7 +117,7 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
|
||||
# or inline resource if it has a filename, a Content-ID, or attachment disposition;
|
||||
# the body text/plain and text/html parts have none of these and are skipped.
|
||||
out: list[ParsedAttachment] = []
|
||||
for part in msg.walk():
|
||||
for index, part in enumerate(msg.walk()):
|
||||
if part.is_multipart() or not isinstance(part, EmailMessage):
|
||||
continue
|
||||
cid = part.get("Content-ID")
|
||||
@@ -137,13 +140,14 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
|
||||
mime_type=part.get_content_type(),
|
||||
content_id=cid.strip("<>") if (inline and cid is not None) else None,
|
||||
payload=payload,
|
||||
part_index=index,
|
||||
)
|
||||
)
|
||||
# An invitation sent only as the text/calendar alternative (Outlook, often) has
|
||||
# no file to show: keep it as invite.ics, so the event can be seen, answered
|
||||
# and downloaded like a Gmail one. Not when the message attaches its own .ics.
|
||||
if not any(a.filename.lower().endswith(".ics") for a in out):
|
||||
for part in msg.walk():
|
||||
for index, part in enumerate(msg.walk()):
|
||||
if part.get_content_type() != "text/calendar" or part.get_filename():
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
@@ -154,6 +158,7 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
|
||||
mime_type="text/calendar",
|
||||
content_id=None,
|
||||
payload=payload,
|
||||
part_index=index,
|
||||
)
|
||||
)
|
||||
break
|
||||
@@ -215,3 +220,36 @@ def _parse_email(msg: EmailMessage) -> ParsedEmail:
|
||||
html_body=_scrub(html_body) if html_body is not None else None,
|
||||
attachments=_extract_attachments(msg),
|
||||
)
|
||||
|
||||
|
||||
def load_message(
|
||||
raw: bytes | None = None, path: str | os.PathLike[str] | None = None
|
||||
) -> EmailMessage:
|
||||
"""The parsed tree of a raw message, from memory or straight from its file."""
|
||||
if path is not None:
|
||||
with open(path, "rb") as handle:
|
||||
msg = email.message_from_binary_file(handle, policy=default_policy)
|
||||
else:
|
||||
msg = email.message_from_bytes(raw or b"", policy=default_policy)
|
||||
assert isinstance(msg, EmailMessage)
|
||||
return msg
|
||||
|
||||
|
||||
def part_payload(msg: EmailMessage, part_index: int | None, sha256: str) -> bytes | None:
|
||||
"""The decoded bytes of the attachment whose content hashes to ``sha256``:
|
||||
the part at ``part_index`` when that is it, else whichever part is (rows
|
||||
stored before the index was kept). None when the message has no such part."""
|
||||
import hashlib
|
||||
|
||||
parts = list(msg.walk())
|
||||
if part_index is not None and 0 <= part_index < len(parts):
|
||||
payload = parts[part_index].get_payload(decode=True)
|
||||
if isinstance(payload, bytes) and hashlib.sha256(payload).hexdigest() == sha256:
|
||||
return payload
|
||||
for part in parts:
|
||||
if part.is_multipart():
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if isinstance(payload, bytes) and hashlib.sha256(payload).hexdigest() == sha256:
|
||||
return payload
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user