diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 27873b8..02dc84f 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -47,6 +47,36 @@ logger = logging.getLogger(__name__) MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25 MB +def _decode_mime_filename(filename: str) -> str: + """Decode MIME-encoded filename, handling =?charset?Q?...?= and =?charset?B?...?= patterns.""" + if not filename: + return "attachment" + # If no MIME encoding pattern, return as-is + if "=?" not in filename: + return filename + try: + from email.header import decode_header, make_header + return str(make_header(decode_header(filename))) + except Exception: + # Fallback: manually decode Q-encoding if decode_header fails + # This handles cases where the email parser partially processed the filename + try: + import re + def decode_q(match): + charset, encoding, encoded = match.group(1), match.group(2).upper(), match.group(3) + if encoding == 'B': + import base64 + decoded = base64.b64decode(encoded).decode(charset or 'utf-8', errors='replace') + else: # Q encoding + decoded = encoded.replace('_', ' ') + decoded = re.sub(r'=([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), decoded) + decoded = decoded.encode('latin-1').decode(charset or 'utf-8', errors='replace') + return decoded + return re.sub(r'=\?([^?]+)\?([BbQq])\?([^?]*)\?=', decode_q, filename) + except Exception: + return filename + + def _sanitize_filename(filename: str) -> str: """Sanitize a filename to prevent path traversal attacks.""" # Remove any path components — keep only the basename @@ -773,11 +803,7 @@ async def imap_sync_account( for att_data in attachments: try: raw_filename = att_data["filename"] or "attachment" - try: - from email.header import decode_header, make_header - decoded_filename = str(make_header(decode_header(raw_filename))) - except Exception: - decoded_filename = raw_filename + decoded_filename = _decode_mime_filename(raw_filename) storage_path = await _save_attachment_to_storage( existing_mail.id, decoded_filename, att_data["content"] ) @@ -831,11 +857,7 @@ async def imap_sync_account( try: # Decode MIME-encoded filenames (e.g. =?utf-8?q?...?=) raw_filename = att_data["filename"] or "attachment" - try: - from email.header import decode_header, make_header - decoded_filename = str(make_header(decode_header(raw_filename))) - except Exception: - decoded_filename = raw_filename + decoded_filename = _decode_mime_filename(raw_filename) storage_path = await _save_attachment_to_storage( mail.id, decoded_filename, att_data["content"] )