Compare commits
2 Commits
94d8c40daa
...
cbe36e0c0e
| Author | SHA1 | Date | |
|---|---|---|---|
| cbe36e0c0e | |||
| 6702d69f7c |
+1
-1
@@ -119,7 +119,7 @@
|
|||||||
| I-G-1 | BUG-022/070 Audits: npm audit = 0 vulnerabilities bereits sauber; pip-audit fand **9 known CVEs in starlette 0.46.2** (PYSEC-2026-161/248/249/1941/1942/2280/2281) — Dilemma: fastapi 0.115.x pinnt starlette<0.47.0, Fixes brauchen >=1.x | ✅ **0 pip findings**: fastapi 0.141.1 (zieht starlette ohne Obergrenze) + starlette direkt auf 1.3.1 gepinnt in requirements.txt; Regressionssmoke auth+api_audit 19/19 + mail+permissions+outbox+audit_middleware+cross_tenant_v2 84/85 (1 Failure = bekannter Reihenfolge-Vorbestand, isolat grün) | 34c9c85 |
|
| I-G-1 | BUG-022/070 Audits: npm audit = 0 vulnerabilities bereits sauber; pip-audit fand **9 known CVEs in starlette 0.46.2** (PYSEC-2026-161/248/249/1941/1942/2280/2281) — Dilemma: fastapi 0.115.x pinnt starlette<0.47.0, Fixes brauchen >=1.x | ✅ **0 pip findings**: fastapi 0.141.1 (zieht starlette ohne Obergrenze) + starlette direkt auf 1.3.1 gepinnt in requirements.txt; Regressionssmoke auth+api_audit 19/19 + mail+permissions+outbox+audit_middleware+cross_tenant_v2 84/85 (1 Failure = bekannter Reihenfolge-Vorbestand, isolat grün) | 34c9c85 |
|
||||||
| I-G-2 | i18n ×258 hardcoded Strings gemessen (Top-Hotspot AISettings.tsx mit 32): Provider-Eigennamen bewusst belassen, ~20 echte UI-Strings | ✅ Exemplarischer Durchstich: useTranslation-Hooks in alle 4 Tab-Komponenten, aiSettings.*-Namespace in de+en ergänzt; tsc=0; AISettings-Tests 18/18; Rest folgt im selben Muster | e7afbaa |
|
| I-G-2 | i18n ×258 hardcoded Strings gemessen (Top-Hotspot AISettings.tsx mit 32): Provider-Eigennamen bewusst belassen, ~20 echte UI-Strings | ✅ Exemplarischer Durchstich: useTranslation-Hooks in alle 4 Tab-Komponenten, aiSettings.*-Namespace in de+en ergänzt; tsc=0; AISettings-Tests 18/18; Rest folgt im selben Muster | e7afbaa |
|
||||||
| I-G-3 | i18n Hotspot Nr.2: ProactiveAISettings.tsx (15+ deutsche Hardcodes inkl. title/toggle/categories/confidence/rateLimit/model/heartbeat/targetRoom + categoryLabels auf t()-Keys) | ✅ **10/10 Tests grün**, tsc=0; proactiveAI.*-Namespace in de+en; categoryLabels-Record durch t()-basierte categoryKeys ersetzt; modelOptions inline mit t()-Labels | 26b5ae9 |
|
| I-G-3 | i18n Hotspot Nr.2: ProactiveAISettings.tsx (15+ deutsche Hardcodes inkl. title/toggle/categories/confidence/rateLimit/model/heartbeat/targetRoom + categoryLabels auf t()-Keys) | ✅ **10/10 Tests grün**, tsc=0; proactiveAI.*-Namespace in de+en; categoryLabels-Record durch t()-basierte categoryKeys ersetzt; modelOptions inline mit t()-Labels | 26b5ae9 |
|
||||||
| I-G-Rest | God Objects: 35 Python-Dateien >500 Z. — Plan verlangt Hotspot-priorisierte Splits mit eigenem Commit je Datei, NICHT Big-Bang | 🟡 **Pilot gestartet**: mail/services.py 3087→2786 Z. (−10%) in 2 Schritten: Schritt 1 = crypto.py+sanitize.py+pgp.py extrahiert (pure functions, Re-Export via noqa F401); Schritt 2 = serializers.py (8 to_response-Funktionen inkl. NEVER-password-Contract) + text_utils.py (extract_email_addresses+_strip_html). Beweise: mail+sig_label_routes **51/51 grün** nach jedem Schritt; ruff clean. Verbleibend in services.py: IMAP Sync (~900 Z.), SMTP Send (~320 Z.), Draft/Folder/Auto-Sync (~500 Z.) — brauchen eigenen Anlauf wegen DB-Session-Mustern | a1d5e56, be81fe5 |
|
| I-G-Rest | God Objects: 35 Python-Dateien >500 Z. — Plan verlangt Hotspot-priorisierte Splits mit eigenem Commit je Datei, NICHT Big-Bang | 🟡 **Pilot läuft**: mail/services.py 3087→~1680 Z. (**−46%**) in 3 Schritten: S1 = crypto+sanitize+pgp (pure functions), S2 = serializers+text_utils (inkl. NEVER-password-Contract), S3 = imap_sync.py (~1070 Z.: folder/account sync, thread-id, German folder names, quota parser) + attachments.py (path-traversal-geschützt) + get_account_password als async (Fix während Extraktion — erste Version war sync und brach send/reply/forward_mail). Re-Export via noqa F401 überall; Beweise: mail+sig_label_routes **51/51 grün** nach jedem Schritt; ruff clean | a1d5e56, be81fe5, 6702d69 |
|
||||||
| G2 | Session-Revocation bei Passwortänderung — Befund differenzierter als Plan annahm: Reset-via-Token (confirm_password_reset) revocierte Sessions bereits korrekt (Redis scan_iter session:*), aber Profil-/Admin-Pfad (users.py PATCH → update_user mit new_password) liess alle anderen Sessions aktiv — Angreifer mit gestohlener Session blieb aktiv | ✅ **120/120 grün** (auth+user_service+rbac_comprehensive in 144s); revoke_user_redis_sessions(user_id)-Helper in auth.py extrahiert (never-raises), von beiden Pfaden genutzt; Postgres sessions-Tabelle unberührt (Audit-Trail by Design) | 0baec27 |
|
| G2 | Session-Revocation bei Passwortänderung — Befund differenzierter als Plan annahm: Reset-via-Token (confirm_password_reset) revocierte Sessions bereits korrekt (Redis scan_iter session:*), aber Profil-/Admin-Pfad (users.py PATCH → update_user mit new_password) liess alle anderen Sessions aktiv — Angreifer mit gestohlener Session blieb aktiv | ✅ **120/120 grün** (auth+user_service+rbac_comprehensive in 144s); revoke_user_redis_sessions(user_id)-Helper in auth.py extrahiert (never-raises), von beiden Pfaden genutzt; Postgres sessions-Tabelle unberührt (Audit-Trail by Design) | 0baec27 |
|
||||||
| G1-a | DSGVO Art. 17 Löschung **nicht funktionsfähig**: POST /dsar/{user_id} queued einen process_dsar-Job der nirgends implementiert war (grep: nur die Route referenziert ihn) — DSAR-Requests verschwanden im Nirvana; Art. 15 Auskunft lieferte nur 3 statt aller versprochenen Kategorien | ✅ **4/4 grün** (test_g1_dsar): _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art. 15/20); _dsar_execute_deletion führt Art. 17 aus — contacts soft-delete (Audit-/Aufbewahrungspflichten respektiert), notifications hard-delete, User anonymisiert + deaktiviert mit FK-Integrität für Audit-Zeilen, dsar_erasure-Audit-Eintrag; process_dsar dispatcht access/deletion/rectification (rectification = manuelle Bearbeitung via Systemnachricht) | f4a5937 |
|
| G1-a | DSGVO Art. 17 Löschung **nicht funktionsfähig**: POST /dsar/{user_id} queued einen process_dsar-Job der nirgends implementiert war (grep: nur die Route referenziert ihn) — DSAR-Requests verschwanden im Nirvana; Art. 15 Auskunft lieferte nur 3 statt aller versprochenen Kategorien | ✅ **4/4 grün** (test_g1_dsar): _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art. 15/20); _dsar_execute_deletion führt Art. 17 aus — contacts soft-delete (Audit-/Aufbewahrungspflichten respektiert), notifications hard-delete, User anonymisiert + deaktiviert mit FK-Integrität für Audit-Zeilen, dsar_erasure-Audit-Eintrag; process_dsar dispatcht access/deletion/rectification (rectification = manuelle Bearbeitung via Systemnachricht) | f4a5937 |
|
||||||
| G1-b | dsgvo-export-Endpoint-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages — geliefert wurden nie welche (Docstring-Fiktion) | ⏳ Export erweitern auf die fehlenden Kategorien als Follow-up (Job-Helfer _dsar_collect_user_data ist der Erweiterungspunkt); Kernpflichten Art. 15/17 sind jetzt funktionsfähig | — |
|
| G1-b | dsgvo-export-Endpoint-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages — geliefert wurden nie welche (Docstring-Fiktion) | ⏳ Export erweitern auf die fehlenden Kategorien als Follow-up (Job-Helfer _dsar_collect_user_data ist der Erweiterungspunkt); Kernpflichten Art. 15/17 sind jetzt funktionsfähig | — |
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Attachment storage helpers for the Mail plugin.
|
||||||
|
|
||||||
|
Extracted from services.py as part of the God-object split (BUG-018 pilot).
|
||||||
|
Re-exported by ``app.plugins.builtins.mail.services``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.plugins.builtins.mail.models import MailAttachment
|
||||||
|
|
||||||
|
# ─── Attachment Storage Helpers ───
|
||||||
|
|
||||||
|
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 base64
|
||||||
|
|
||||||
|
def decode_q(match):
|
||||||
|
charset, encoding, encoded = (
|
||||||
|
match.group(1),
|
||||||
|
match.group(2).upper(),
|
||||||
|
match.group(3),
|
||||||
|
)
|
||||||
|
if encoding == "B":
|
||||||
|
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
|
||||||
|
filename = os.path.basename(filename or "attachment")
|
||||||
|
# Replace potentially dangerous characters
|
||||||
|
filename = re.sub(r"[^a-zA-Z0-9._-]", "_", filename)
|
||||||
|
# Ensure non-empty
|
||||||
|
if not filename:
|
||||||
|
filename = "attachment"
|
||||||
|
# Limit length
|
||||||
|
if len(filename) > 200:
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
filename = name[:200 - len(ext)] + ext
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_storage_path(mail_id: uuid.UUID, filename: str) -> str:
|
||||||
|
"""Build the on-disk storage path for a mail attachment."""
|
||||||
|
safe_name = _sanitize_filename(filename)
|
||||||
|
return os.path.join(
|
||||||
|
settings.storage_path,
|
||||||
|
"mail_attachments",
|
||||||
|
str(mail_id),
|
||||||
|
safe_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_attachment_to_storage(
|
||||||
|
mail_id: uuid.UUID, filename: str, content: bytes
|
||||||
|
) -> str:
|
||||||
|
"""Save attachment content to disk and return the storage path."""
|
||||||
|
storage_path = _attachment_storage_path(mail_id, filename)
|
||||||
|
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
|
||||||
|
async with aiofiles.open(storage_path, "wb") as f:
|
||||||
|
await f.write(content)
|
||||||
|
return storage_path
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_to_response(att: MailAttachment) -> dict:
|
||||||
|
"""Convert a MailAttachment ORM object to a response dict."""
|
||||||
|
return {
|
||||||
|
"id": str(att.id),
|
||||||
|
"mail_id": str(att.mail_id),
|
||||||
|
"filename": att.filename,
|
||||||
|
"mime_type": att.mime_type,
|
||||||
|
"size_bytes": att.size_bytes,
|
||||||
|
"size": att.size_bytes, # alias for frontend compatibility
|
||||||
|
"content_id": att.content_id,
|
||||||
|
"is_inline": bool(att.content_id),
|
||||||
|
"dms_file_id": str(att.dms_file_id) if att.dms_file_id else None,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user