harden(mail): address PR #935 round-4 review

No blockers raised; hardening + clarity:
- client/mail.py: URL-encode the caller-supplied attachment_id
  (quote(..., safe="")) — defense-in-depth against path traversal.
- server/mail.py: measure attachment content in UTF-8 bytes (not characters)
  for the size cap and the sentinel message.
- scanner.py: bound _mail_cap_logged (insertion-ordered dict + oldest-first
  eviction at 50k, mirroring _consent_backstop_done) so the cap-log dedup set
  can't leak in a long-running multi-tenant process; reword the cap log to not
  imply MAIL_SCAN_MAX_PER_MAILBOX is operator-tunable (it's the Mail OCS max).
- models/mail.py: comment why GetAttachmentResponse doesn't nest MailAttachment
  (different OCS endpoint shape).
- mail_content.py: document format_mail_addresses' empty-entry skip contract.

_potentially_deleted doc_type-in-key remains tracked as Deck #376 (pre-existing
cross-cutting; reviewer confirmed deferral).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-20 13:12:15 +02:00
co-authored by Claude Opus 4.8
parent 891d07db12
commit 0856d59956
5 changed files with 54 additions and 17 deletions
+7 -1
View File
@@ -173,7 +173,13 @@ class GetMessageResponse(BaseResponse):
class GetAttachmentResponse(BaseResponse):
"""Response model for getting a single attachment."""
"""Response model for getting a single attachment.
Intentionally does NOT nest ``MailAttachment``: the Mail OCS *get-attachment*
endpoint returns a different shape (``name``/``mime``/``size``/``content``)
than the attachment entries on a message listing, which ``MailAttachment``
models (``id``/``fileName``/``cid``/``disposition``, and no ``content``).
"""
name: str | None = Field(None, description="Attachment file name")
mime: str | None = Field(None, description="MIME type")