""" Client MinIO/S3 asincrono per il worker. Percorso EML raw: pechub/tenants/{tenant_id}/mailboxes/{mailbox_id}/raw/{uid}.eml Percorso allegati: pechub/tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{msg_id}/{filename} """ import io import logging from functools import lru_cache from miniopy_async import Minio from app.config import get_settings logger = logging.getLogger(__name__) settings = get_settings() @lru_cache(maxsize=1) def get_minio_client() -> Minio: """Restituisce l'istanza singleton del client MinIO.""" return Minio( endpoint=settings.minio_endpoint, access_key=settings.minio_access_key, secret_key=settings.minio_secret_key, secure=settings.minio_use_ssl, ) async def upload_eml( tenant_id: str, mailbox_id: str, uid: int, eml_bytes: bytes, ) -> str: """ Carica un raw EML su MinIO e restituisce il percorso oggetto. Percorso: tenants/{tenant_id}/mailboxes/{mailbox_id}/raw/{uid}.eml """ client = get_minio_client() bucket = settings.minio_bucket object_path = f"tenants/{tenant_id}/mailboxes/{mailbox_id}/raw/{uid}.eml" try: data_stream = io.BytesIO(eml_bytes) await client.put_object( bucket_name=bucket, object_name=object_path, data=data_stream, length=len(eml_bytes), content_type="message/rfc822", ) logger.debug(f"EML caricato: s3://{bucket}/{object_path} ({len(eml_bytes)} bytes)") return object_path except Exception as e: logger.error(f"Errore upload EML {object_path}: {e}") raise async def upload_attachment( tenant_id: str, mailbox_id: str, message_id: str, filename: str, content: bytes, content_type: str = "application/octet-stream", ) -> str: """ Carica un allegato su MinIO e restituisce il percorso oggetto. Percorso: tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{message_id}/{filename} Args: tenant_id: UUID del tenant mailbox_id: UUID della casella message_id: UUID del messaggio a cui appartiene l'allegato filename: nome file dell'allegato (usato nel path) content: byte del file content_type: MIME type del file Returns: Percorso oggetto su MinIO (senza il nome bucket) """ client = get_minio_client() bucket = settings.minio_bucket # Sanitizza il filename per evitare path traversal safe_filename = _sanitize_filename(filename) object_path = ( f"tenants/{tenant_id}/mailboxes/{mailbox_id}" f"/attachments/{message_id}/{safe_filename}" ) try: data_stream = io.BytesIO(content) await client.put_object( bucket_name=bucket, object_name=object_path, data=data_stream, length=len(content), content_type=content_type, ) logger.debug( f"Allegato caricato: s3://{bucket}/{object_path} " f"({len(content)} bytes, {content_type})" ) return object_path except Exception as e: logger.error(f"Errore upload allegato {object_path}: {e}") raise def _sanitize_filename(filename: str) -> str: """ Sanitizza il nome file per uso sicuro come path MinIO. Rimuove caratteri pericolosi mantenendo l'estensione originale. """ import re # Rimuovi path separators e null bytes safe = filename.replace("/", "_").replace("\\", "_").replace("\x00", "") # Rimuovi caratteri non ASCII e di controllo safe = re.sub(r"[^\w.\-() ]", "_", safe, flags=re.UNICODE) # Limita la lunghezza (MinIO ha limite 1024 chars per object name) if len(safe) > 200: # Mantieni estensione parts = safe.rsplit(".", 1) if len(parts) == 2: safe = parts[0][:196] + "." + parts[1] else: safe = safe[:200] return safe or "attachment" async def upload_outbound_eml( tenant_id: str, mailbox_id: str, message_id: str, eml_bytes: bytes, ) -> str: """ Carica il raw EML di un messaggio outbound su MinIO. Percorso: tenants/{tenant_id}/mailboxes/{mailbox_id}/outbound/{message_id}.eml Args: tenant_id: UUID del tenant mailbox_id: UUID della casella mittente message_id: UUID del messaggio eml_bytes: byte del raw EML Returns: Percorso oggetto su MinIO (senza bucket name) """ client = get_minio_client() bucket = settings.minio_bucket object_path = ( f"tenants/{tenant_id}/mailboxes/{mailbox_id}/outbound/{message_id}.eml" ) try: import io as _io data_stream = _io.BytesIO(eml_bytes) await client.put_object( bucket_name=bucket, object_name=object_path, data=data_stream, length=len(eml_bytes), content_type="message/rfc822", ) logger.debug( f"EML outbound caricato: s3://{bucket}/{object_path} " f"({len(eml_bytes)} bytes)" ) return object_path except Exception as e: logger.error(f"Errore upload EML outbound {object_path}: {e}") raise async def download_attachment(storage_path: str) -> bytes: """ Scarica un allegato da MinIO e restituisce i byte. Args: storage_path: percorso oggetto MinIO (senza bucket name) Returns: Byte del file scaricato Raises: Exception: se il download fallisce """ client = get_minio_client() bucket = settings.minio_bucket try: response = await client.get_object( bucket_name=bucket, object_name=storage_path, ) data = await response.read() response.close() await response.release() logger.debug( f"Allegato scaricato: s3://{bucket}/{storage_path} ({len(data)} bytes)" ) return data except Exception as e: logger.error(f"Errore download allegato {storage_path}: {e}") raise async def ensure_bucket_exists() -> None: """Verifica che il bucket MinIO esista, altrimenti lo crea.""" client = get_minio_client() bucket = settings.minio_bucket try: found = await client.bucket_exists(bucket) if not found: await client.make_bucket(bucket) logger.info(f"Bucket MinIO creato: {bucket}") else: logger.debug(f"Bucket MinIO esistente: {bucket}") except Exception as e: logger.warning(f"Impossibile verificare/creare bucket MinIO: {e}")