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 -5
View File
@@ -222,11 +222,13 @@ def configure_mail_tools(mcp: FastMCP):
try:
data = await client.mail.get_attachment(message_id, attachment_id)
content = data.get("content")
if isinstance(content, str) and len(content) > MAX_ATTACHMENT_CONTENT_BYTES:
content = (
f"[attachment too large to inline: {len(content)} bytes "
f"(> {MAX_ATTACHMENT_CONTENT_BYTES})]"
)
if isinstance(content, str):
content_bytes = len(content.encode("utf-8"))
if content_bytes > MAX_ATTACHMENT_CONTENT_BYTES:
content = (
f"[attachment too large to inline: {content_bytes} bytes "
f"(> {MAX_ATTACHMENT_CONTENT_BYTES})]"
)
return GetAttachmentResponse(
name=data.get("name"),
mime=data.get("mime"),