refactor(i-g): BUG-018 Pilot Split Schritt 2 — serializers+text_utils extrahiert
Check Cross-Plugin Imports / check (push) Has been cancelled

serializers.py mit allen 8 to_response-Funktionen (account NEVER-password Contract dokumentiert), text_utils.py mit extract_email_addresses+_strip_html als pure functions. Re-Export via noqa F401 in services.py — alle Consumer unveraendert. services.py jetzt 2786 Z. (von 3087).

Beweis: mail+sig_label_routes 51/51 passed nach ruff --fix; ruff clean.
This commit is contained in:
Agent Zero
2026-08-26 09:38:34 +02:00
parent a1d5e56009
commit be81fe52cf
3 changed files with 228 additions and 183 deletions
+169
View File
@@ -0,0 +1,169 @@
"""Response serializers for the Mail plugin ORM models.
Extracted from services.py as part of the God-object split (BUG-018 pilot).
Re-exported by ``app.plugins.builtins.mail.services``.
account_to_response NEVER includes passwords — security-critical contract.
"""
from __future__ import annotations
import json
from app.plugins.builtins.mail.models import (
Mail,
MailAccount,
MailAttachment,
MailFolder,
MailLabel,
MailRule,
MailSignature,
MailTemplate,
)
from app.plugins.builtins.mail.text_utils import (
extract_email_addresses, # noqa: F401 — re-exported for backwards compat
)
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,
}
def account_to_response(account: MailAccount) -> dict:
"""Convert MailAccount to response dict, NEVER including password."""
return {
"id": str(account.id),
"email": account.email_address,
"email_address": account.email_address,
"display_name": account.display_name,
"imap_host": account.imap_host,
"imap_port": account.imap_port,
"imap_ssl": account.imap_ssl,
"smtp_host": account.smtp_host,
"smtp_port": account.smtp_port,
"smtp_tls": account.smtp_tls,
"username": account.username,
"is_shared": account.is_shared,
"is_active": account.is_active,
"sent_folder_imap_name": account.sent_folder_imap_name,
"drafts_folder_imap_name": account.drafts_folder_imap_name,
"spam_folder_imap_name": account.spam_folder_imap_name,
"trash_folder_imap_name": account.trash_folder_imap_name,
"created_at": account.created_at,
"updated_at": account.updated_at,
}
def mail_to_response(
mail: Mail,
attachments: list[MailAttachment] | None = None,
labels: list[MailLabel] | None = None,
) -> dict:
"""Convert a Mail ORM object to a response dict."""
resp = {
"id": str(mail.id),
"account_id": str(mail.account_id),
"folder_id": str(mail.folder_id),
"message_id": mail.message_id,
"thread_id": mail.thread_id,
"in_reply_to": mail.in_reply_to,
"subject": mail.subject,
"from_address": mail.from_address,
"from_name": mail.from_address.split("<")[0].strip().strip('"') if "<" in mail.from_address else mail.from_address,
"to_addresses": extract_email_addresses(mail.to_addresses) if mail.to_addresses else [],
"cc_addresses": extract_email_addresses(mail.cc_addresses) if mail.cc_addresses else [],
"bcc_addresses": extract_email_addresses(mail.bcc_addresses) if mail.bcc_addresses else [],
"body_text": mail.body_text,
"body_html": mail.body_html if mail.body_html else None,
"body_html_sanitized": mail.body_html_sanitized,
"sanitized_html": mail.body_html_sanitized,
"date": mail.received_at.isoformat() if mail.received_at else (mail.sent_at.isoformat() if mail.sent_at else None),
"is_seen": mail.is_seen,
"is_flagged": mail.is_flagged,
"flag_type": getattr(mail, 'flag_type', None),
"is_draft": mail.is_draft,
"is_answered": mail.is_answered,
"is_forwarded": mail.is_forwarded,
"has_attachments": mail.has_attachments,
"size_bytes": mail.size_bytes,
"received_at": mail.received_at,
"sent_at": mail.sent_at,
"contact_id": str(mail.contact_id) if mail.contact_id else None,
"attachments": [],
"labels": [],
}
if attachments:
resp["attachments"] = [attachment_to_response(a) for a in attachments]
if labels:
resp["labels"] = [
{"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels
]
return resp
def folder_to_response(folder: MailFolder) -> dict:
"""Convert MailFolder to response dict."""
return {
"id": str(folder.id),
"account_id": str(folder.account_id),
"name": folder.name,
"imap_name": folder.imap_name,
"parent_id": str(folder.parent_id) if folder.parent_id else None,
"is_standard": folder.is_standard,
"unread_count": folder.unread_count,
"total_count": folder.total_count,
}
def rule_to_response(rule: MailRule) -> dict:
"""Convert MailRule to response dict."""
return {
"id": str(rule.id),
"name": rule.name,
"account_id": str(rule.account_id) if rule.account_id else None,
"priority": rule.priority,
"is_active": rule.is_active,
"conditions": json.loads(rule.conditions) if rule.conditions else {},
"actions": json.loads(rule.actions) if rule.actions else {},
}
def template_to_response(template: MailTemplate) -> dict:
"""Convert MailTemplate to response dict."""
return {
"id": str(template.id),
"name": template.name,
"subject": template.subject,
"body_html": template.body_html,
}
def signature_to_response(sig: MailSignature) -> dict:
"""Convert MailSignature to response dict."""
return {
"id": str(sig.id),
"name": sig.name,
"body_html": sig.body_html,
"account_id": str(sig.account_id) if sig.account_id else None,
"is_default": sig.is_default,
}
def label_to_response(label: MailLabel) -> dict:
"""Convert MailLabel to response dict."""
return {
"id": str(label.id),
"name": label.name,
"color": label.color,
}
+24 -183
View File
@@ -29,25 +29,37 @@ from app.plugins.builtins.mail.crypto import ( # noqa: E402
encrypt_password,
generate_salt,
)
from app.plugins.builtins.mail.models import (
Mail,
MailAccount,
MailAttachment,
MailFolder,
MailLabelAssignment,
MailRule,
MailSignature,
VacationSentLog,
)
from app.plugins.builtins.mail.pgp import ( # noqa: E402,F401
import_pgp_private_key,
import_pgp_public_key,
pgp_decrypt_message,
pgp_encrypt_message,
)
from app.plugins.builtins.mail.models import (
Mail,
MailAccount,
MailAttachment,
MailFolder,
MailLabel,
MailLabelAssignment,
MailRule,
MailSignature,
MailTemplate,
VacationSentLog,
)
from app.plugins.builtins.mail.sanitize import sanitize_html # noqa: E402,F401
from app.plugins.builtins.mail.serializers import ( # noqa: E402,F401
account_to_response,
attachment_to_response,
folder_to_response,
label_to_response,
mail_to_response,
rule_to_response,
signature_to_response,
template_to_response,
)
from app.plugins.builtins.mail.text_utils import ( # noqa: E402,F401
_strip_html,
extract_email_addresses,
)
logger = logging.getLogger(__name__)
@@ -124,21 +136,6 @@ async def _save_attachment_to_storage(
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,
}
# ─── IMAP Quota Parser ───
@@ -276,31 +273,6 @@ async def get_account_password(account: MailAccount) -> str:
return decrypt_password(account.encrypted_password, account.password_salt or None)
def account_to_response(account: MailAccount) -> dict:
"""Convert MailAccount to response dict, NEVER including password."""
return {
"id": str(account.id),
"email": account.email_address,
"email_address": account.email_address,
"display_name": account.display_name,
"imap_host": account.imap_host,
"imap_port": account.imap_port,
"imap_ssl": account.imap_ssl,
"smtp_host": account.smtp_host,
"smtp_port": account.smtp_port,
"smtp_tls": account.smtp_tls,
"username": account.username,
"is_shared": account.is_shared,
"is_active": account.is_active,
"sent_folder_imap_name": account.sent_folder_imap_name,
"drafts_folder_imap_name": account.drafts_folder_imap_name,
"spam_folder_imap_name": account.spam_folder_imap_name,
"trash_folder_imap_name": account.trash_folder_imap_name,
"created_at": account.created_at,
"updated_at": account.updated_at,
}
# ─── IMAP Sync Service (F-MAIL-01) ───
@@ -1867,137 +1839,6 @@ async def log_vacation_sent(
# ─── Contact Linking (F-MAIL-10) ───
def extract_email_addresses(text: str) -> list[str]:
"""Extract email addresses from a text string."""
if not text:
return []
return re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text)
# ─── Utility ───
def _strip_html(html: str) -> str:
"""Simple HTML to text conversion for plain text fallback."""
if not html:
return ""
# Remove tags
text = re.sub(r"<[^>]+>", "", html)
# Replace HTML entities
text = (
text.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
)
return text.strip()
def mail_to_response(
mail: Mail,
attachments: list[MailAttachment] | None = None,
labels: list[MailLabel] | None = None,
) -> dict:
"""Convert a Mail ORM object to a response dict."""
resp = {
"id": str(mail.id),
"account_id": str(mail.account_id),
"folder_id": str(mail.folder_id),
"message_id": mail.message_id,
"thread_id": mail.thread_id,
"in_reply_to": mail.in_reply_to,
"subject": mail.subject,
"from_address": mail.from_address,
"from_name": mail.from_address.split("<")[0].strip().strip('"') if "<" in mail.from_address else mail.from_address,
"to_addresses": extract_email_addresses(mail.to_addresses) if mail.to_addresses else [],
"cc_addresses": extract_email_addresses(mail.cc_addresses) if mail.cc_addresses else [],
"bcc_addresses": extract_email_addresses(mail.bcc_addresses) if mail.bcc_addresses else [],
"body_text": mail.body_text,
"body_html": mail.body_html if mail.body_html else None,
"body_html_sanitized": mail.body_html_sanitized,
"sanitized_html": mail.body_html_sanitized,
"date": mail.received_at.isoformat() if mail.received_at else (mail.sent_at.isoformat() if mail.sent_at else None),
"is_seen": mail.is_seen,
"is_flagged": mail.is_flagged,
"flag_type": getattr(mail, 'flag_type', None),
"is_draft": mail.is_draft,
"is_answered": mail.is_answered,
"is_forwarded": mail.is_forwarded,
"has_attachments": mail.has_attachments,
"size_bytes": mail.size_bytes,
"received_at": mail.received_at,
"sent_at": mail.sent_at,
"contact_id": str(mail.contact_id) if mail.contact_id else None,
"attachments": [],
"labels": [],
}
if attachments:
resp["attachments"] = [attachment_to_response(a) for a in attachments]
if labels:
resp["labels"] = [
{"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels
]
return resp
def folder_to_response(folder: MailFolder) -> dict:
"""Convert MailFolder to response dict."""
return {
"id": str(folder.id),
"account_id": str(folder.account_id),
"name": folder.name,
"imap_name": folder.imap_name,
"parent_id": str(folder.parent_id) if folder.parent_id else None,
"is_standard": folder.is_standard,
"unread_count": folder.unread_count,
"total_count": folder.total_count,
}
def rule_to_response(rule: MailRule) -> dict:
"""Convert MailRule to response dict."""
return {
"id": str(rule.id),
"name": rule.name,
"account_id": str(rule.account_id) if rule.account_id else None,
"priority": rule.priority,
"is_active": rule.is_active,
"conditions": json.loads(rule.conditions) if rule.conditions else {},
"actions": json.loads(rule.actions) if rule.actions else {},
}
def template_to_response(template: MailTemplate) -> dict:
"""Convert MailTemplate to response dict."""
return {
"id": str(template.id),
"name": template.name,
"subject": template.subject,
"body_html": template.body_html,
}
def signature_to_response(sig: MailSignature) -> dict:
"""Convert MailSignature to response dict."""
return {
"id": str(sig.id),
"name": sig.name,
"body_html": sig.body_html,
"account_id": str(sig.account_id) if sig.account_id else None,
"is_default": sig.is_default,
}
def label_to_response(label: MailLabel) -> dict:
"""Convert MailLabel to response dict."""
return {
"id": str(label.id),
"name": label.name,
"color": label.color,
}
# ─── IMAP Flag Sync ───
+35
View File
@@ -0,0 +1,35 @@
"""Text utilities 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 re
def extract_email_addresses(text: str) -> list[str]:
"""Extract email addresses from a text string."""
if not text:
return []
return re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text)
def _strip_html(html: str) -> str:
"""Simple HTML to text conversion for plain text fallback."""
if not html:
return ""
# Remove tags
text = re.sub(r"<[^>]+>", "", html)
# Replace HTML entities
text = (
text.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&#39;", "'")
)
# Collapse whitespace
return re.sub(r"\s+", " ", text).strip()