"""Outlook Sync Routes - GMAO Collège""" from flask import Blueprint, current_app, render_template, redirect, url_for, request, flash, jsonify, send_file from flask_login import login_required, current_user from app_new.extensions import db from ..outlook.models import OutlookAccount, OutlookMail, OutlookFolder, OutlookAttachment, OutlookMailInterpretation, GmaoContext from ..outlook.auth import get_access_token from ..core.models.maintenance import WatchdogLog import os import subprocess import json import tempfile sync_bp = Blueprint('outlook_sync', __name__, template_folder='templates') def _sync_error_message(exc, *, account_id, folder_id=None, mail_data=None): """Produit un journal utile sans inclure le corps ni les destinataires du mail.""" original = getattr(exc, 'orig', None) error_code = getattr(original, 'args', [None])[0] if original else None subject = ((mail_data or {}).get('Subject') or '(sans objet)')[:160] mail_id = ((mail_data or {}).get('Id') or '(inconnu)')[:180] content = ((mail_data or {}).get('Body') or {}).get('Content') or '' detail = str(original or exc).split('[SQL:', 1)[0].strip()[:500] return ( f"Synchronisation Outlook impossible pour le message {mail_id} " f"(compte={account_id}, dossier={folder_id or 'inbox'}, objet={subject!r}, " f"corps={len(content.encode('utf-8'))} octets, code={error_code}): {detail}" ) def _store_sync_error(message): """Ajoute une erreur à la page /logs/ dans la transaction courante.""" db.session.add(WatchdogLog( watchdog_name='outlook_sync', level='error', message=message[:10000], )) @sync_bp.route('/api/sync-folders/', methods=['POST']) @login_required def api_sync_folders(account_id): """Synchronise les dossiers Outlook (avec sous-dossiers récursifs).""" import requests from datetime import datetime, timezone account = OutlookAccount.query.get(account_id) if not account or (account.user_id != current_user.id and not current_user.is_admin()): return jsonify({'error': 'Compte non trouvé'}), 404 access_token, error = get_access_token(account) if error: return jsonify({'error': error}), 401 try: headers = {'Authorization': f'Bearer {access_token}'} # Fonction récursive pour récupérer les dossiers def fetch_folders(parent_id=None, depth=0): if depth > 5: # Limiter la profondeur return [] folders = [] if parent_id: url = f'https://outlook.office365.com/api/v2.0/me/MailFolders/{parent_id}/ChildFolders' else: url = 'https://outlook.office365.com/api/v2.0/me/MailFolders' while url: resp = requests.get(url, headers=headers) if resp.status_code != 200: break data = resp.json() for folder_data in data.get('value', []): folders.append(folder_data) # Récupérer les sous-dossiers récursivement child_folders = fetch_folders(folder_data.get('Id'), depth + 1) folders.extend(child_folders) url = data.get('@odata.nextLink') return folders all_folders = fetch_folders() synced = 0 for folder_data in all_folders: folder_id = folder_data.get('Id') existing = OutlookFolder.query.filter_by(id=folder_id).first() if not existing: folder_name = folder_data.get('DisplayName') or folder_data.get('Name') or 'Inconnu' folder_type = folder_data.get('WellKnownFolderName', 'custom') default_folders = { 'inbox': 'Boîte de réception', 'sentitems': 'Éléments envoyés', 'drafts': 'Brouillons', 'deleteditems': 'Éléments supprimés', 'junkemail': 'Courrier indésirable', 'outbox': "Boîte d'envoi", 'archive': 'Archive' } display_name = default_folders.get(folder_type, folder_name) folder = OutlookFolder( id=folder_id, account_id=account.id, name=folder_name, display_name=display_name, parent_id=folder_data.get('ParentFolderId'), total_items=folder_data.get('TotalItemCount', 0), unread_items=folder_data.get('UnreadItemCount', 0), folder_type=folder_type ) db.session.add(folder) synced += 1 else: existing.total_items = folder_data.get('TotalItemCount', 0) existing.unread_items = folder_data.get('UnreadItemCount', 0) folder_name = folder_data.get('DisplayName') or folder_data.get('Name') or existing.name existing.name = folder_name folder_type = folder_data.get('WellKnownFolderName', 'custom') default_folders = { 'inbox': 'Boîte de réception', 'sentitems': 'Éléments envoyés', 'drafts': 'Brouillons', 'deleteditems': 'Éléments supprimés', 'junkemail': 'Courrier indésirable', 'outbox': "Boîte d'envoi", 'archive': 'Archive' } existing.display_name = default_folders.get(folder_type, folder_name) db.session.commit() return jsonify({ 'success': True, 'message': f'{synced} nouveaux dossiers synchronisés', 'total': len(all_folders) }) except Exception as e: import traceback traceback.print_exc() return jsonify({'error': f'Erreur: {str(e)}'}), 500 @sync_bp.route('/api/sync-mails//', methods=['POST']) @sync_bp.route('/api/sync-mails/', defaults={'folder_id': 'inbox'}, methods=['POST']) @login_required def api_sync_mails(account_id, folder_id='inbox'): """Synchronise les mails d'un dossier avec pagination complète.""" import requests from datetime import datetime, timezone account = OutlookAccount.query.get(account_id) if not account or (account.user_id != current_user.id and not current_user.is_admin()): return jsonify({'error': 'Compte non trouvé'}), 404 access_token, error = get_access_token(account) if error: return jsonify({'error': error}), 401 # Paramètres de pagination max_messages = max(1, min(request.args.get('max', 200, type=int) or 200, 5000)) try: # Trouver le dossier folder = None if folder_id != 'inbox': folder = OutlookFolder.query.filter_by(id=folder_id, account_id=account_id).first() else: # Chercher par folder_type ou par nom (Boîte de réception) folder = OutlookFolder.query.filter_by(account_id=account_id, folder_type='inbox').first() if not folder: folder = OutlookFolder.query.filter( OutlookFolder.account_id == account_id, OutlookFolder.name.like('%ception%') ).first() if not folder: return jsonify({'error': 'Dossier non trouvé'}), 404 headers = {'Authorization': f'Bearer {access_token}'} # Récupérer tous les messages avec pagination all_mails = [] url = f'https://outlook.office365.com/api/v2.0/me/MailFolders/{folder.id}/messages?$top=100&$orderby=ReceivedDateTime desc&$select=Id,Subject,From,ReceivedDateTime,IsRead,HasAttachments,BodyPreview,Body,ToRecipients,CcRecipients,Importance' while url and len(all_mails) < max_messages: resp = requests.get(url, headers=headers) if resp.status_code != 200: error_data = resp.json() error_msg = error_data.get('error', {}).get('message', resp.text) if isinstance(error_data, dict) else resp.text return jsonify({'error': f'Erreur API Outlook: {error_msg}'}), 500 data = resp.json() mails_data = data.get('value', []) all_mails.extend(mails_data) # Vérifier s'il y a une page suivante url = data.get('@odata.nextLink') # Arrêter si on a atteint la limite if len(all_mails) >= max_messages: break saved_count = 0 updated_count = 0 skipped_count = 0 for mail_data in all_mails: try: # Le SAVEPOINT et le flush immédiat empêchent un message # invalide de faire échouer les suivants par autoflush. with db.session.begin_nested(): mail_id = mail_data.get('Id') existing = OutlookMail.query.filter_by(id=mail_id).first() from_addr = mail_data.get('From', {}).get('EmailAddress', {}) body = mail_data.get('Body', {}) received_str = mail_data.get('ReceivedDateTime', '') try: received_at = datetime.fromisoformat(received_str.replace('Z', '+00:00')) except (TypeError, ValueError): received_at = datetime.now(timezone.utc) to_recipients = json.dumps([r.get('EmailAddress', {}).get('Address', '') for r in mail_data.get('ToRecipients', [])]) cc_recipients = json.dumps([r.get('EmailAddress', {}).get('Address', '') for r in mail_data.get('CcRecipients', [])]) if not existing: db.session.add(OutlookMail( id=mail_id, account_id=account.id, folder_id=folder.id, subject=mail_data.get('Subject', ''), from_name=from_addr.get('Name', ''), from_email=from_addr.get('Address', ''), body_preview=mail_data.get('BodyPreview', ''), body_content=body.get('Content', ''), body_type=body.get('ContentType', 'text'), received_at=received_at, is_read=mail_data.get('IsRead', False), importance=mail_data.get('Importance', 'normal'), has_attachments=mail_data.get('HasAttachments', False), to_recipients=to_recipients, cc_recipients=cc_recipients, )) db.session.flush() saved_count += 1 elif existing.is_read != mail_data.get('IsRead', False): existing.is_read = mail_data.get('IsRead', False) db.session.flush() updated_count += 1 except Exception as mail_error: skipped_count += 1 message = _sync_error_message( mail_error, account_id=account.id, folder_id=folder.id, mail_data=mail_data, ) current_app.logger.warning(message, exc_info=True) _store_sync_error(message) # Mettre à jour les infos du dossier folder.total_items = len(all_mails) folder.unread_items = sum(1 for m in all_mails if not m.get('IsRead', False)) account.last_connected_at = datetime.now(timezone.utc) db.session.commit() return jsonify({ 'success': True, 'message': f'{saved_count} nouveaux mails, {updated_count} mis à jour sur {len(all_mails)} récupérés', 'total': len(all_mails), 'new': saved_count, 'updated': updated_count, 'skipped': skipped_count, 'warning': f'{skipped_count} message(s) ignoré(s), détails dans /logs/.' if skipped_count else None, }) except Exception as e: current_app.logger.exception('Échec global de la synchronisation Outlook') db.session.rollback() message = _sync_error_message( e, account_id=account_id, folder_id=folder_id, ) try: _store_sync_error(message) db.session.commit() except Exception: db.session.rollback() current_app.logger.exception("Impossible d'enregistrer l'erreur Outlook dans /logs/") return jsonify({'error': 'La synchronisation Outlook a échoué. Consultez /logs/ pour le détail.'}), 500 # ─── Pièces jointes ───────────────────────────────────────────────────────── @sync_bp.route('/api/sync-attachments/', methods=['POST']) @login_required def api_sync_attachments(mail_id): """Synchronise les pièces jointes d'un email.""" import requests mail = OutlookMail.query.get_or_404(mail_id) account = OutlookAccount.query.get_or_404(mail.account_id) if account.user_id != current_user.id and not current_user.is_admin(): return jsonify({'error': 'Message non trouvé'}), 404 access_token, error = get_access_token(account) if error: return jsonify({'error': error}), 401 try: headers = {'Authorization': f'Bearer {access_token}'} resp = requests.get( f'https://outlook.office365.com/api/v2.0/me/messages/{mail_id}/attachments', headers=headers ) if resp.status_code != 200: return jsonify({'error': f'Erreur API: {resp.text}'}), 500 attachments_data = resp.json().get('value', []) synced = 0 for att_data in attachments_data: att_id = att_data.get('Id') existing = OutlookAttachment.query.filter_by(id=att_id).first() if not existing: attachment = OutlookAttachment( id=att_id, mail_id=mail_id, name=att_data.get('Name', 'sans_nom'), content_type=att_data.get('ContentType'), size=att_data.get('Size', 0), is_inline=att_data.get('IsInline', False), content_id=att_data.get('ContentId') ) db.session.add(attachment) synced += 1 db.session.commit() return jsonify({ 'success': True, 'message': f'{synced} nouvelles pièces jointes', 'total': len(attachments_data) }) except Exception as e: import traceback traceback.print_exc() return jsonify({'error': f'Erreur: {str(e)}'}), 500 @sync_bp.route('/attachment//download') @login_required def download_attachment(att_id): """Télécharge une pièce jointe.""" import requests attachment = OutlookAttachment.query.get_or_404(att_id) mail = OutlookMail.query.get_or_404(attachment.mail_id) account = OutlookAccount.query.get_or_404(mail.account_id) if account.user_id != current_user.id and not current_user.is_admin(): return jsonify({'error': 'Pièce jointe non trouvée'}), 404 access_token, error = get_access_token(account) if error: flash(error, 'danger') return redirect(url_for('outlook_pages.mail_view', account_id=account.id, mail_id=mail.id)) try: headers = {'Authorization': f'Bearer {access_token}'} resp = requests.get( f'https://outlook.office365.com/api/v2.0/me/messages/{mail.id}/attachments/{att_id}/$value', headers=headers, stream=True ) if resp.status_code != 200: flash('Erreur lors du téléchargement', 'danger') return redirect(url_for('outlook_pages.mail_view', account_id=account.id, mail_id=mail.id)) # Sauvegarder temporairement import tempfile temp_dir = tempfile.gettempdir() temp_path = os.path.join(temp_dir, attachment.name) with open(temp_path, 'wb') as f: for chunk in resp.iter_content(chunk_size=8192): f.write(chunk) return send_file(temp_path, as_attachment=True, download_name=attachment.name) except Exception as e: flash(f'Erreur: {str(e)}', 'danger') return redirect(url_for('outlook_pages.mail_view', account_id=account.id, mail_id=mail.id))