gmao/app_new/outlook/sync.py
root 372671204d
Some checks failed
CI - Tests et Syntax / lint-and-test (push) Has been cancelled
Fiabilise la synchronisation des grands mails Outlook
2026-08-15 00:07:14 +00:00

397 lines
17 KiB
Python

"""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/<int:account_id>', 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/<int:account_id>/<folder_id>', methods=['POST'])
@sync_bp.route('/api/sync-mails/<int:account_id>', 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/<mail_id>', 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/<att_id>/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))